diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/governance/mod.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/governance/mod.rs index 355640e66fe0..dab23b2f6c1a 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/governance/mod.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/governance/mod.rs @@ -119,6 +119,8 @@ parameter_types! { pub const MaxPeerInHeartbeats: u32 = 10_000; pub const MaxBalance: Balance = Balance::max_value(); pub TreasuryAccount: AccountId = Treasury::account_id(); + pub const MaxQueuedSpends: u32 = 100; + pub const OrderExpirationPeriod: BlockNumber = 2 * DAYS; } pub type TreasurySpender = EitherOf, Spender>; @@ -153,6 +155,8 @@ impl pallet_treasury::Config for Runtime { type BalanceConverter = TreasuryBalanceConverter; type PayoutPeriod = PayoutSpendPeriod; type BlockNumberProvider = RelaychainDataProvider; + type MaxQueuedSpends = MaxQueuedSpends; + type OrderExpirationPeriod = OrderExpirationPeriod; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = parachains_common::pay::benchmarks::LocalPayArguments< xcm_config::TrustBackedAssetsPalletIndex, diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs index 5eb1f05a4ce7..cca86487244a 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs @@ -2001,6 +2001,8 @@ pub type Migrations = ( Runtime, TrappedBalanceMember, >, + // unreleased + pallet_treasury::migration::MigrateToOrderedPayouts, // permanent pallet_xcm::migration::MigrateToLatestXcmVersion, cumulus_pallet_aura_ext::migration::MigrateV0ToV1, diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_treasury.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_treasury.rs index bd90482f8f47..48ec063be7b5 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_treasury.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_treasury.rs @@ -1,5 +1,4 @@ // Copyright (C) Parity Technologies (UK) Ltd. -// This file is part of Cumulus. // SPDX-License-Identifier: Apache-2.0 // Licensed under the Apache License, Version 2.0 (the "License"); @@ -16,25 +15,29 @@ //! Autogenerated weights for `pallet_treasury` //! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev -//! DATE: 2023-07-07, STEPS: `50`, REPEAT: `2`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 +//! DATE: 2026-06-29, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `cob`, CPU: `` -//! EXECUTION: None, WASM-EXECUTION: Compiled, CHAIN: Some("rococo-dev"), DB CACHE: 1024 +//! HOSTNAME: `c37d1d4d187b`, CPU: `Intel(R) Xeon(R) CPU @ 2.60GHz` +//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: 1024 // Executed Command: -// ./target/debug/polkadot +// frame-omni-bencher +// v1 // benchmark // pallet -// --chain=rococo-dev -// --steps=50 -// --repeat=2 -// --pallet=pallet_treasury // --extrinsic=* +// --runtime=target/production/wbuild/asset-hub-westend-runtime/asset_hub_westend_runtime.wasm +// --pallet=pallet_treasury +// --header=/__w/polkadot-sdk/polkadot-sdk/cumulus/file_header.txt +// --output=./cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights // --wasm-execution=compiled +// --steps=50 +// --repeat=20 // --heap-pages=4096 -// --output=./runtime/rococo/src/weights/ -// --header=./file_header.txt +// --no-storage-info +// --no-min-squares +// --no-median-slopes #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -47,132 +50,171 @@ use core::marker::PhantomData; /// Weight functions for `pallet_treasury`. pub struct WeightInfo(PhantomData); impl pallet_treasury::WeightInfo for WeightInfo { - /// Storage: Treasury ProposalCount (r:1 w:1) - /// Proof: Treasury ProposalCount (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) - /// Storage: Treasury Approvals (r:1 w:1) - /// Proof: Treasury Approvals (max_values: Some(1), max_size: Some(402), added: 897, mode: MaxEncodedLen) - /// Storage: Treasury Proposals (r:0 w:1) - /// Proof: Treasury Proposals (max_values: None, max_size: Some(108), added: 2583, mode: MaxEncodedLen) + /// Storage: `Treasury::ProposalCount` (r:1 w:1) + /// Proof: `Treasury::ProposalCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Treasury::Approvals` (r:1 w:1) + /// Proof: `Treasury::Approvals` (`max_values`: Some(1), `max_size`: Some(402), added: 897, mode: `MaxEncodedLen`) + /// Storage: `Treasury::Proposals` (r:0 w:1) + /// Proof: `Treasury::Proposals` (`max_values`: None, `max_size`: Some(108), added: 2583, mode: `MaxEncodedLen`) fn spend_local() -> Weight { // Proof Size summary in bytes: - // Measured: `42` + // Measured: `76` // Estimated: `1887` - // Minimum execution time: 177_000_000 picoseconds. - Weight::from_parts(191_000_000, 0) + // Minimum execution time: 13_106_000 picoseconds. + Weight::from_parts(14_516_000, 0) .saturating_add(Weight::from_parts(0, 1887)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(3)) } - /// Storage: Treasury Approvals (r:1 w:1) - /// Proof: Treasury Approvals (max_values: Some(1), max_size: Some(402), added: 897, mode: MaxEncodedLen) + /// Storage: `Treasury::Approvals` (r:1 w:1) + /// Proof: `Treasury::Approvals` (`max_values`: Some(1), `max_size`: Some(402), added: 897, mode: `MaxEncodedLen`) fn remove_approval() -> Weight { // Proof Size summary in bytes: - // Measured: `127` + // Measured: `161` // Estimated: `1887` - // Minimum execution time: 80_000_000 picoseconds. - Weight::from_parts(82_000_000, 0) + // Minimum execution time: 7_279_000 picoseconds. + Weight::from_parts(8_029_000, 0) .saturating_add(Weight::from_parts(0, 1887)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: Treasury Deactivated (r:1 w:1) - /// Proof: Treasury Deactivated (max_values: Some(1), max_size: Some(16), added: 511, mode: MaxEncodedLen) - /// Storage: Balances InactiveIssuance (r:1 w:1) - /// Proof: Balances InactiveIssuance (max_values: Some(1), max_size: Some(16), added: 511, mode: MaxEncodedLen) - /// Storage: Treasury Approvals (r:1 w:1) - /// Proof: Treasury Approvals (max_values: Some(1), max_size: Some(402), added: 897, mode: MaxEncodedLen) - /// Storage: Treasury Proposals (r:99 w:99) - /// Proof: Treasury Proposals (max_values: None, max_size: Some(108), added: 2583, mode: MaxEncodedLen) - /// Storage: System Account (r:199 w:199) - /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) - /// Storage: Bounties BountyApprovals (r:1 w:1) - /// Proof: Bounties BountyApprovals (max_values: Some(1), max_size: Some(402), added: 897, mode: MaxEncodedLen) + /// Storage: `ParachainSystem::ValidationData` (r:1 w:0) + /// Proof: `ParachainSystem::ValidationData` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `ParachainSystem::LastRelayChainBlockNumber` (r:1 w:0) + /// Proof: `ParachainSystem::LastRelayChainBlockNumber` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `System::Account` (r:1 w:0) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Treasury::Deactivated` (r:1 w:1) + /// Proof: `Treasury::Deactivated` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Treasury::LastSpendPeriod` (r:1 w:1) + /// Proof: `Treasury::LastSpendPeriod` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// The range of component `p` is `[0, 99]`. fn on_initialize_proposals(p: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `331 + p * (251 ±0)` - // Estimated: `3593 + p * (5206 ±0)` - // Minimum execution time: 887_000_000 picoseconds. - Weight::from_parts(828_616_021, 0) + // Measured: `1444` + // Estimated: `3593` + // Minimum execution time: 21_280_000 picoseconds. + Weight::from_parts(29_151_412, 0) .saturating_add(Weight::from_parts(0, 3593)) - // Standard Error: 695_351 - .saturating_add(Weight::from_parts(566_114_524, 0).saturating_mul(p.into())) + // Standard Error: 1_379 + .saturating_add(Weight::from_parts(74_362, 0).saturating_mul(p.into())) .saturating_add(T::DbWeight::get().reads(5)) - .saturating_add(T::DbWeight::get().reads((3_u64).saturating_mul(p.into()))) - .saturating_add(T::DbWeight::get().writes(5)) - .saturating_add(T::DbWeight::get().writes((3_u64).saturating_mul(p.into()))) - .saturating_add(Weight::from_parts(0, 5206).saturating_mul(p.into())) + .saturating_add(T::DbWeight::get().writes(2)) } - /// Storage: AssetRate ConversionRateToNative (r:1 w:0) - /// Proof: AssetRate ConversionRateToNative (max_values: None, max_size: Some(1237), added: 3712, mode: MaxEncodedLen) - /// Storage: Treasury SpendCount (r:1 w:1) - /// Proof: Treasury SpendCount (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) - /// Storage: Treasury Spends (r:0 w:1) - /// Proof: Treasury Spends (max_values: None, max_size: Some(1848), added: 4323, mode: MaxEncodedLen) + /// Storage: `ParachainSystem::ValidationData` (r:1 w:0) + /// Proof: `ParachainSystem::ValidationData` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `ParachainSystem::LastRelayChainBlockNumber` (r:1 w:0) + /// Proof: `ParachainSystem::LastRelayChainBlockNumber` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `AssetRate::ConversionRateToNative` (r:1 w:0) + /// Proof: `AssetRate::ConversionRateToNative` (`max_values`: None, `max_size`: Some(1238), added: 3713, mode: `MaxEncodedLen`) + /// Storage: `Treasury::SpendCount` (r:1 w:1) + /// Proof: `Treasury::SpendCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(2016), added: 4491, mode: `MaxEncodedLen`) + /// Storage: `Treasury::Spends` (r:0 w:1) + /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(2456), added: 4931, mode: `MaxEncodedLen`) fn spend() -> Weight { // Proof Size summary in bytes: - // Measured: `114` - // Estimated: `4702` - // Minimum execution time: 208_000_000 picoseconds. - Weight::from_parts(222_000_000, 0) - .saturating_add(Weight::from_parts(0, 4702)) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(2)) + // Measured: `1072` + // Estimated: `5481` + // Minimum execution time: 39_144_000 picoseconds. + Weight::from_parts(41_448_000, 0) + .saturating_add(Weight::from_parts(0, 5481)) + .saturating_add(T::DbWeight::get().reads(6)) + .saturating_add(T::DbWeight::get().writes(4)) } - /// Storage: Treasury Spends (r:1 w:1) - /// Proof: Treasury Spends (max_values: None, max_size: Some(1848), added: 4323, mode: MaxEncodedLen) - /// Storage: XcmPallet QueryCounter (r:1 w:1) - /// Proof Skipped: XcmPallet QueryCounter (max_values: Some(1), max_size: None, mode: Measured) - /// Storage: Configuration ActiveConfig (r:1 w:0) - /// Proof Skipped: Configuration ActiveConfig (max_values: Some(1), max_size: None, mode: Measured) - /// Storage: Dmp DeliveryFeeFactor (r:1 w:0) - /// Proof Skipped: Dmp DeliveryFeeFactor (max_values: None, max_size: None, mode: Measured) - /// Storage: XcmPallet SupportedVersion (r:1 w:0) - /// Proof Skipped: XcmPallet SupportedVersion (max_values: None, max_size: None, mode: Measured) - /// Storage: XcmPallet VersionDiscoveryQueue (r:1 w:1) - /// Proof Skipped: XcmPallet VersionDiscoveryQueue (max_values: Some(1), max_size: None, mode: Measured) - /// Storage: XcmPallet SafeXcmVersion (r:1 w:0) - /// Proof Skipped: XcmPallet SafeXcmVersion (max_values: Some(1), max_size: None, mode: Measured) - /// Storage: Dmp DownwardMessageQueues (r:1 w:1) - /// Proof Skipped: Dmp DownwardMessageQueues (max_values: None, max_size: None, mode: Measured) - /// Storage: Dmp DownwardMessageQueueHeads (r:1 w:1) - /// Proof Skipped: Dmp DownwardMessageQueueHeads (max_values: None, max_size: None, mode: Measured) - /// Storage: XcmPallet Queries (r:0 w:1) - /// Proof Skipped: XcmPallet Queries (max_values: None, max_size: None, mode: Measured) + /// Storage: `Treasury::Spends` (r:1 w:1) + /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(2456), added: 4931, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:0) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) + /// Storage: `ParachainSystem::ValidationData` (r:1 w:0) + /// Proof: `ParachainSystem::ValidationData` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `ParachainSystem::LastRelayChainBlockNumber` (r:1 w:0) + /// Proof: `ParachainSystem::LastRelayChainBlockNumber` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:2 w:2) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `AssetsHolder::BalancesOnHold` (r:1 w:1) + /// Proof: `AssetsHolder::BalancesOnHold` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `MaxEncodedLen`) + /// Storage: `AssetsFreezer::FrozenBalances` (r:1 w:1) + /// Proof: `AssetsFreezer::FrozenBalances` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `AssetsFreezer::Freezes` (r:1 w:1) + /// Proof: `AssetsFreezer::Freezes` (`max_values`: None, `max_size`: Some(123), added: 2598, mode: `MaxEncodedLen`) + /// Storage: `AssetsHolder::Holds` (r:1 w:1) + /// Proof: `AssetsHolder::Holds` (`max_values`: None, `max_size`: Some(411), added: 2886, mode: `MaxEncodedLen`) + /// Storage: `Revive::OriginalAccount` (r:1 w:1) + /// Proof: `Revive::OriginalAccount` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) fn payout() -> Weight { // Proof Size summary in bytes: - // Measured: `737` - // Estimated: `5313` - // Minimum execution time: 551_000_000 picoseconds. - Weight::from_parts(569_000_000, 0) - .saturating_add(Weight::from_parts(0, 5313)) - .saturating_add(T::DbWeight::get().reads(9)) - .saturating_add(T::DbWeight::get().writes(6)) + // Measured: `5037` + // Estimated: `6522` + // Minimum execution time: 141_719_000 picoseconds. + Weight::from_parts(150_487_000, 0) + .saturating_add(Weight::from_parts(0, 6522)) + .saturating_add(T::DbWeight::get().reads(14)) + .saturating_add(T::DbWeight::get().writes(11)) } - /// Storage: Treasury Spends (r:1 w:1) - /// Proof: Treasury Spends (max_values: None, max_size: Some(1848), added: 4323, mode: MaxEncodedLen) - /// Storage: XcmPallet Queries (r:1 w:1) - /// Proof Skipped: XcmPallet Queries (max_values: None, max_size: None, mode: Measured) + /// Storage: `Treasury::Spends` (r:1 w:1) + /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(2456), added: 4931, mode: `MaxEncodedLen`) + /// Storage: `ParachainSystem::ValidationData` (r:1 w:0) + /// Proof: `ParachainSystem::ValidationData` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `ParachainSystem::LastRelayChainBlockNumber` (r:1 w:0) + /// Proof: `ParachainSystem::LastRelayChainBlockNumber` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(2016), added: 4491, mode: `MaxEncodedLen`) fn check_status() -> Weight { // Proof Size summary in bytes: - // Measured: `442` - // Estimated: `5313` - // Minimum execution time: 245_000_000 picoseconds. - Weight::from_parts(281_000_000, 0) - .saturating_add(Weight::from_parts(0, 5313)) - .saturating_add(T::DbWeight::get().reads(2)) + // Measured: `1116` + // Estimated: `5921` + // Minimum execution time: 36_048_000 picoseconds. + Weight::from_parts(38_142_000, 0) + .saturating_add(Weight::from_parts(0, 5921)) + .saturating_add(T::DbWeight::get().reads(5)) + .saturating_add(T::DbWeight::get().writes(3)) + } + /// Storage: `Treasury::Spends` (r:1 w:0) + /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(2456), added: 4931, mode: `MaxEncodedLen`) + /// Storage: `ParachainSystem::ValidationData` (r:1 w:0) + /// Proof: `ParachainSystem::ValidationData` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(2016), added: 4491, mode: `MaxEncodedLen`) + fn check_status_rotation() -> Weight { + // Proof Size summary in bytes: + // Measured: `1129` + // Estimated: `5921` + // Minimum execution time: 33_764_000 picoseconds. + Weight::from_parts(35_650_000, 0) + .saturating_add(Weight::from_parts(0, 5921)) + .saturating_add(T::DbWeight::get().reads(4)) .saturating_add(T::DbWeight::get().writes(2)) } - /// Storage: Treasury Spends (r:1 w:1) - /// Proof: Treasury Spends (max_values: None, max_size: Some(1848), added: 4323, mode: MaxEncodedLen) + /// Storage: `Treasury::Spends` (r:1 w:1) + /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(2456), added: 4931, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(2016), added: 4491, mode: `MaxEncodedLen`) + /// Storage: `ParachainSystem::ValidationData` (r:1 w:0) + /// Proof: `ParachainSystem::ValidationData` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `ParachainSystem::LastRelayChainBlockNumber` (r:1 w:0) + /// Proof: `ParachainSystem::LastRelayChainBlockNumber` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn void_spend() -> Weight { // Proof Size summary in bytes: - // Measured: `172` - // Estimated: `5313` - // Minimum execution time: 147_000_000 picoseconds. - Weight::from_parts(160_000_000, 0) - .saturating_add(Weight::from_parts(0, 5313)) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) + // Measured: `1108` + // Estimated: `5921` + // Minimum execution time: 30_391_000 picoseconds. + Weight::from_parts(32_216_000, 0) + .saturating_add(Weight::from_parts(0, 5921)) + .saturating_add(T::DbWeight::get().reads(5)) + .saturating_add(T::DbWeight::get().writes(3)) } } diff --git a/cumulus/parachains/runtimes/collectives/collectives-westend/src/fellowship/mod.rs b/cumulus/parachains/runtimes/collectives/collectives-westend/src/fellowship/mod.rs index 4bbd03173946..4a632d406dc6 100644 --- a/cumulus/parachains/runtimes/collectives/collectives-westend/src/fellowship/mod.rs +++ b/cumulus/parachains/runtimes/collectives/collectives-westend/src/fellowship/mod.rs @@ -335,6 +335,8 @@ impl pallet_treasury::Config for Runtime { AssetRate, >; type PayoutPeriod = ConstU32<{ 30 * DAYS }>; + type MaxQueuedSpends = ConstU32<100>; + type OrderExpirationPeriod = ConstU32<{ 3 * DAYS }>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = polkadot_runtime_common::impls::benchmarks::TreasuryArguments< sp_core::ConstU8<1>, diff --git a/cumulus/parachains/runtimes/collectives/collectives-westend/src/lib.rs b/cumulus/parachains/runtimes/collectives/collectives-westend/src/lib.rs index 87c9c17e6e05..552e200c892f 100644 --- a/cumulus/parachains/runtimes/collectives/collectives-westend/src/lib.rs +++ b/cumulus/parachains/runtimes/collectives/collectives-westend/src/lib.rs @@ -871,6 +871,11 @@ type Migrations = ( pallet_session::migrations::v1::InitOffenceSeverity, >, cumulus_pallet_parachain_system::migration::Migration, + // unreleased + pallet_treasury::migration::MigrateToOrderedPayouts< + Runtime, + fellowship::FellowshipTreasuryInstance, + >, ); // Helpers for the core fellowship pallet v1->v2 storage migration. diff --git a/cumulus/parachains/runtimes/collectives/collectives-westend/src/weights/pallet_treasury.rs b/cumulus/parachains/runtimes/collectives/collectives-westend/src/weights/pallet_treasury.rs index 5e4a5a8b1edc..2a281e551e68 100644 --- a/cumulus/parachains/runtimes/collectives/collectives-westend/src/weights/pallet_treasury.rs +++ b/cumulus/parachains/runtimes/collectives/collectives-westend/src/weights/pallet_treasury.rs @@ -16,9 +16,9 @@ //! Autogenerated weights for `pallet_treasury` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 -//! DATE: 2025-02-21, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-06-29, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `e0f303704c84`, CPU: `Intel(R) Xeon(R) CPU @ 2.60GHz` +//! HOSTNAME: `1807571685ea`, CPU: `Intel(R) Xeon(R) CPU @ 2.60GHz` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: 1024 // Executed Command: @@ -58,10 +58,10 @@ impl pallet_treasury::WeightInfo for WeightInfo { /// Proof: `FellowshipTreasury::Proposals` (`max_values`: None, `max_size`: Some(108), added: 2583, mode: `MaxEncodedLen`) fn spend_local() -> Weight { // Proof Size summary in bytes: - // Measured: `76` + // Measured: `109` // Estimated: `1887` - // Minimum execution time: 12_879_000 picoseconds. - Weight::from_parts(13_346_000, 0) + // Minimum execution time: 12_453_000 picoseconds. + Weight::from_parts(13_366_000, 0) .saturating_add(Weight::from_parts(0, 1887)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(3)) @@ -70,10 +70,10 @@ impl pallet_treasury::WeightInfo for WeightInfo { /// Proof: `FellowshipTreasury::Approvals` (`max_values`: Some(1), `max_size`: Some(402), added: 897, mode: `MaxEncodedLen`) fn remove_approval() -> Weight { // Proof Size summary in bytes: - // Measured: `161` + // Measured: `194` // Estimated: `1887` - // Minimum execution time: 6_978_000 picoseconds. - Weight::from_parts(7_278_000, 0) + // Minimum execution time: 6_619_000 picoseconds. + Weight::from_parts(7_219_000, 0) .saturating_add(Weight::from_parts(0, 1887)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -87,13 +87,13 @@ impl pallet_treasury::WeightInfo for WeightInfo { /// The range of component `p` is `[0, 99]`. fn on_initialize_proposals(p: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `280 + p * (1 ±0)` + // Measured: `313 + p * (1 ±0)` // Estimated: `3593` - // Minimum execution time: 13_758_000 picoseconds. - Weight::from_parts(17_033_109, 0) + // Minimum execution time: 13_437_000 picoseconds. + Weight::from_parts(16_723_316, 0) .saturating_add(Weight::from_parts(0, 3593)) - // Standard Error: 1_091 - .saturating_add(Weight::from_parts(70_962, 0).saturating_mul(p.into())) + // Standard Error: 398 + .saturating_add(Weight::from_parts(66_219, 0).saturating_mul(p.into())) .saturating_add(T::DbWeight::get().reads(3)) .saturating_add(T::DbWeight::get().writes(2)) } @@ -103,20 +103,26 @@ impl pallet_treasury::WeightInfo for WeightInfo { /// Proof: `AssetRate::ConversionRateToNative` (`max_values`: None, `max_size`: Some(1238), added: 3713, mode: `MaxEncodedLen`) /// Storage: `FellowshipTreasury::SpendCount` (r:1 w:1) /// Proof: `FellowshipTreasury::SpendCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `FellowshipTreasury::NextPayout` (r:1 w:1) + /// Proof: `FellowshipTreasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) + /// Storage: `FellowshipTreasury::PayoutQueue` (r:1 w:1) + /// Proof: `FellowshipTreasury::PayoutQueue` (`max_values`: None, `max_size`: Some(2016), added: 4491, mode: `MaxEncodedLen`) /// Storage: `FellowshipTreasury::Spends` (r:0 w:1) /// Proof: `FellowshipTreasury::Spends` (`max_values`: None, `max_size`: Some(1853), added: 4328, mode: `MaxEncodedLen`) fn spend() -> Weight { // Proof Size summary in bytes: - // Measured: `219` - // Estimated: `4703` - // Minimum execution time: 24_150_000 picoseconds. - Weight::from_parts(24_739_000, 0) - .saturating_add(Weight::from_parts(0, 4703)) - .saturating_add(T::DbWeight::get().reads(3)) - .saturating_add(T::DbWeight::get().writes(2)) + // Measured: `1147` + // Estimated: `5481` + // Minimum execution time: 37_925_000 picoseconds. + Weight::from_parts(39_956_000, 0) + .saturating_add(Weight::from_parts(0, 5481)) + .saturating_add(T::DbWeight::get().reads(5)) + .saturating_add(T::DbWeight::get().writes(4)) } /// Storage: `FellowshipTreasury::Spends` (r:1 w:1) /// Proof: `FellowshipTreasury::Spends` (`max_values`: None, `max_size`: Some(1853), added: 4328, mode: `MaxEncodedLen`) + /// Storage: `FellowshipTreasury::NextPayout` (r:1 w:0) + /// Proof: `FellowshipTreasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) /// Storage: `ParachainInfo::ParachainId` (r:1 w:0) /// Proof: `ParachainInfo::ParachainId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `PolkadotXcm::QueryCounter` (r:1 w:1) @@ -125,48 +131,72 @@ impl pallet_treasury::WeightInfo for WeightInfo { /// Proof: `XcmpQueue::DeliveryFeeFactor` (`max_values`: None, `max_size`: Some(28), added: 2503, mode: `MaxEncodedLen`) /// Storage: `PolkadotXcm::SupportedVersion` (r:1 w:0) /// Proof: `PolkadotXcm::SupportedVersion` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `XcmpQueue::OutboundXcmpStatus` (r:1 w:1) + /// Proof: `XcmpQueue::OutboundXcmpStatus` (`max_values`: Some(1), `max_size`: Some(2306), added: 2801, mode: `MaxEncodedLen`) /// Storage: `ParachainSystem::RelevantMessagingState` (r:1 w:0) /// Proof: `ParachainSystem::RelevantMessagingState` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `XcmpQueue::OutboundXcmpStatus` (r:1 w:1) - /// Proof: `XcmpQueue::OutboundXcmpStatus` (`max_values`: Some(1), `max_size`: Some(1282), added: 1777, mode: `MaxEncodedLen`) /// Storage: `XcmpQueue::OutboundXcmpMessages` (r:0 w:1) /// Proof: `XcmpQueue::OutboundXcmpMessages` (`max_values`: None, `max_size`: Some(105506), added: 107981, mode: `MaxEncodedLen`) /// Storage: `PolkadotXcm::Queries` (r:0 w:1) /// Proof: `PolkadotXcm::Queries` (`max_values`: None, `max_size`: None, mode: `Measured`) fn payout() -> Weight { // Proof Size summary in bytes: - // Measured: `559` + // Measured: `568` // Estimated: `5318` - // Minimum execution time: 59_146_000 picoseconds. - Weight::from_parts(62_110_000, 0) + // Minimum execution time: 61_546_000 picoseconds. + Weight::from_parts(65_150_000, 0) .saturating_add(Weight::from_parts(0, 5318)) - .saturating_add(T::DbWeight::get().reads(7)) + .saturating_add(T::DbWeight::get().reads(8)) .saturating_add(T::DbWeight::get().writes(5)) } /// Storage: `FellowshipTreasury::Spends` (r:1 w:1) /// Proof: `FellowshipTreasury::Spends` (`max_values`: None, `max_size`: Some(1853), added: 4328, mode: `MaxEncodedLen`) + /// Storage: `FellowshipTreasury::NextPayout` (r:1 w:1) + /// Proof: `FellowshipTreasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) /// Storage: `PolkadotXcm::Queries` (r:1 w:1) /// Proof: `PolkadotXcm::Queries` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `FellowshipTreasury::PayoutQueue` (r:1 w:1) + /// Proof: `FellowshipTreasury::PayoutQueue` (`max_values`: None, `max_size`: Some(2016), added: 4491, mode: `MaxEncodedLen`) fn check_status() -> Weight { // Proof Size summary in bytes: - // Measured: `240` - // Estimated: `5318` - // Minimum execution time: 25_460_000 picoseconds. - Weight::from_parts(26_237_000, 0) - .saturating_add(Weight::from_parts(0, 5318)) - .saturating_add(T::DbWeight::get().reads(2)) + // Measured: `1176` + // Estimated: `5481` + // Minimum execution time: 40_938_000 picoseconds. + Weight::from_parts(43_298_000, 0) + .saturating_add(Weight::from_parts(0, 5481)) + .saturating_add(T::DbWeight::get().reads(4)) + .saturating_add(T::DbWeight::get().writes(4)) + } + /// Storage: `FellowshipTreasury::Spends` (r:1 w:0) + /// Proof: `FellowshipTreasury::Spends` (`max_values`: None, `max_size`: Some(1853), added: 4328, mode: `MaxEncodedLen`) + /// Storage: `FellowshipTreasury::NextPayout` (r:1 w:1) + /// Proof: `FellowshipTreasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) + /// Storage: `FellowshipTreasury::PayoutQueue` (r:1 w:1) + /// Proof: `FellowshipTreasury::PayoutQueue` (`max_values`: None, `max_size`: Some(2016), added: 4491, mode: `MaxEncodedLen`) + fn check_status_rotation() -> Weight { + // Proof Size summary in bytes: + // Measured: `1140` + // Estimated: `5481` + // Minimum execution time: 30_501_000 picoseconds. + Weight::from_parts(32_604_000, 0) + .saturating_add(Weight::from_parts(0, 5481)) + .saturating_add(T::DbWeight::get().reads(3)) .saturating_add(T::DbWeight::get().writes(2)) } /// Storage: `FellowshipTreasury::Spends` (r:1 w:1) /// Proof: `FellowshipTreasury::Spends` (`max_values`: None, `max_size`: Some(1853), added: 4328, mode: `MaxEncodedLen`) + /// Storage: `FellowshipTreasury::NextPayout` (r:1 w:1) + /// Proof: `FellowshipTreasury::NextPayout` (`max_values`: None, `max_size`: Some(1226), added: 3701, mode: `MaxEncodedLen`) + /// Storage: `FellowshipTreasury::PayoutQueue` (r:1 w:1) + /// Proof: `FellowshipTreasury::PayoutQueue` (`max_values`: None, `max_size`: Some(2016), added: 4491, mode: `MaxEncodedLen`) fn void_spend() -> Weight { // Proof Size summary in bytes: - // Measured: `212` - // Estimated: `5318` - // Minimum execution time: 15_357_000 picoseconds. - Weight::from_parts(15_787_000, 0) - .saturating_add(Weight::from_parts(0, 5318)) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) + // Measured: `1148` + // Estimated: `5481` + // Minimum execution time: 28_370_000 picoseconds. + Weight::from_parts(30_215_000, 0) + .saturating_add(Weight::from_parts(0, 5481)) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(3)) } } diff --git a/polkadot/runtime/common/src/impls.rs b/polkadot/runtime/common/src/impls.rs index 3e9e5e6c04c7..96f75d427f4f 100644 --- a/polkadot/runtime/common/src/impls.rs +++ b/polkadot/runtime/common/src/impls.rs @@ -400,6 +400,8 @@ mod tests { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<0>; type BlockNumberProvider = System; + type MaxQueuedSpends = frame_support::traits::ConstU32<100>; + type OrderExpirationPeriod = ConstU64<10>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/polkadot/runtime/rococo/src/lib.rs b/polkadot/runtime/rococo/src/lib.rs index 76950c83c0bc..b4b32936bc94 100644 --- a/polkadot/runtime/rococo/src/lib.rs +++ b/polkadot/runtime/rococo/src/lib.rs @@ -512,6 +512,7 @@ impl pallet_session::historical::Config for Runtime { parameter_types! { pub const SessionsPerEra: SessionIndex = 6; pub const BondingDuration: sp_staking::EraIndex = 28; + } parameter_types! { @@ -532,6 +533,8 @@ parameter_types! { pub const MaxKeys: u32 = 10_000; pub const MaxPeerInHeartbeats: u32 = 10_000; pub const MaxBalance: Balance = Balance::max_value(); + pub const MaxQueuedSpends: u32 = 100; + pub const OrderExpirationPeriod: BlockNumber = 2 * DAYS; } impl pallet_treasury::Config for Runtime { @@ -570,6 +573,8 @@ impl pallet_treasury::Config for Runtime { >; type PayoutPeriod = PayoutSpendPeriod; type BlockNumberProvider = System; + type MaxQueuedSpends = MaxQueuedSpends; + type OrderExpirationPeriod = OrderExpirationPeriod; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = polkadot_runtime_common::impls::benchmarks::TreasuryArguments; } @@ -1755,7 +1760,7 @@ pub mod migrations { pallet_elections_phragmen::migrations::unlock_and_unreserve_all_funds::UnlockAndUnreserveAllFunds, pallet_democracy::migrations::unlock_and_unreserve_all_funds::UnlockAndUnreserveAllFunds, pallet_tips::migrations::unreserve_deposits::UnreserveDeposits, - pallet_treasury::migration::cleanup_proposals::Migration, + pallet_treasury::migration::CleanupProposalsMigration, // Delete all Gov v1 pallet storage key/values. @@ -1781,6 +1786,8 @@ pub mod migrations { parachains_configuration::migration::v13::MigrateToV13, parachains_shared::migration::MigrateToV2, + pallet_treasury::migration::MigrateToOrderedPayouts, + // permanent pallet_xcm::migration::MigrateToLatestXcmVersion, parachains_inclusion::migration::MigrateToV1, diff --git a/polkadot/runtime/rococo/src/weights/pallet_treasury.rs b/polkadot/runtime/rococo/src/weights/pallet_treasury.rs index c875202a22fe..7c6549de45cf 100644 --- a/polkadot/runtime/rococo/src/weights/pallet_treasury.rs +++ b/polkadot/runtime/rococo/src/weights/pallet_treasury.rs @@ -19,7 +19,6 @@ //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 //! DATE: 2025-02-22, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `d3a9aad6f7a3`, CPU: `Intel(R) Xeon(R) CPU @ 2.60GHz` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: 1024 // Executed Command: @@ -152,6 +151,16 @@ impl pallet_treasury::WeightInfo for WeightInfo { .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(2)) } + fn check_status_rotation() -> Weight { + // Proof Size summary in bytes: + // Measured: `305` + // Estimated: `5318` + // Minimum execution time: 25_238_000 picoseconds. + Weight::from_parts(25_654_000, 0) + .saturating_add(Weight::from_parts(0, 5318)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } /// Storage: `Treasury::Spends` (r:1 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(1853), added: 4328, mode: `MaxEncodedLen`) fn void_spend() -> Weight { diff --git a/prdoc/pr_11603.prdoc b/prdoc/pr_11603.prdoc new file mode 100644 index 000000000000..03ffae4eeca3 --- /dev/null +++ b/prdoc/pr_11603.prdoc @@ -0,0 +1,56 @@ +title: 'pallet-treasury: Ordered Payouts' +doc: +- audience: Runtime Dev + description: |- + Implements ordered spend payouts (Solution 1.2 of https://github.com/paritytech/polkadot-sdk/issues/11100) + for `pallet-treasury`. Spends are now paid out in order per asset kind, preventing smaller, + later-approved spends from draining the treasury before earlier-approved spends can be paid. + + Two new storage items are introduced: `NextPayout`, holding the spend currently at the head of + the payout order for each asset kind together with its order expiration block, and + `PayoutQueue`, a bounded vector of `(spend_index, order_key)` entries per asset kind sorted by + order key. The order key is `max(now, valid_from)` for newly inserted spends, so a later + approval never overtakes an already-mature spend, and the block number of the rotation for + spends rotated to the back after their order expired. + + No new extrinsics are added; ordering is enforced transparently through the existing calls: + + - `spend` inserts the approved spend into the payout queue for its asset kind, or sets it as + `NextPayout` if there is none, with an order expiration of + `max(now, valid_from) + OrderExpirationPeriod`. + - `payout` only succeeds for the spend designated as `NextPayout` for its asset kind and fails + with the new `NotNextPayout` error otherwise. + - `check_status` additionally rotates an expired head back into the queue (emitting the new + `PayoutQueueRotated` event) and promotes the next entry on removal of a paid-out or expired + spend. + - `void_spend` also removes the spend from the payout order and promotes the next entry. + + Runtimes adopting this need to configure two new `Config` items: `MaxQueuedSpends` (bounds the + queue per asset kind; spends fail with the new `QueueFull` error once reached) and + `OrderExpirationPeriod` (how long a spend may block the head of the order before it can be + rotated). A `MigrateToOrderedPayouts` migration is provided to populate the payout order from + the existing `Spends` storage; it is a `VersionedMigration` bumping the pallet storage version + from 0 to 1. +crates: +- name: pallet-treasury + bump: major +- name: rococo-runtime + bump: minor +- name: asset-hub-westend-runtime + bump: minor +- name: collectives-westend-runtime + bump: minor +- name: kitchensink-runtime + bump: minor +- name: pallet-staking-async-parachain-runtime + bump: minor +- name: pallet-staking-async-rc-runtime + bump: minor +- name: polkadot-runtime-common + bump: none +- name: pallet-bounties + bump: none +- name: pallet-child-bounties + bump: none +- name: pallet-tips + bump: none diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index 14a0614aa13d..6ad23b129b4e 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -1328,6 +1328,8 @@ parameter_types! { pub const MaxApprovals: u32 = 100; pub const MaxBalance: Balance = Balance::max_value(); pub const SpendPayoutPeriod: BlockNumber = 30 * DAYS; + pub const MaxQueuedSpends: u32 = 100; + pub const OrderExpirationPeriod: BlockNumber = 10 * HOURS; } impl pallet_treasury::Config for Runtime { @@ -1352,6 +1354,8 @@ impl pallet_treasury::Config for Runtime { type BalanceConverter = AssetRate; type PayoutPeriod = SpendPayoutPeriod; type BlockNumberProvider = System; + type MaxQueuedSpends = MaxQueuedSpends; + type OrderExpirationPeriod = OrderExpirationPeriod; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = PalletTreasuryArguments; } @@ -3026,6 +3030,7 @@ type Migrations = ( pallet_alliance::migration::Migration, pallet_contracts::Migration, pallet_identity::migration::versioned::V0ToV1, + pallet_treasury::migration::MigrateToOrderedPayouts, ); type EventRecord = frame_system::EventRecord< diff --git a/substrate/frame/bounties/src/tests.rs b/substrate/frame/bounties/src/tests.rs index 812ae5b8e10a..cc3fca6430b7 100644 --- a/substrate/frame/bounties/src/tests.rs +++ b/substrate/frame/bounties/src/tests.rs @@ -113,6 +113,8 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type MaxQueuedSpends = ConstU32<100>; + type OrderExpirationPeriod = ConstU64<10>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } @@ -136,6 +138,8 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type MaxQueuedSpends = ConstU32<100>; + type OrderExpirationPeriod = ConstU64<10>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/substrate/frame/child-bounties/src/tests.rs b/substrate/frame/child-bounties/src/tests.rs index e0bb1ac6f75a..6c2697e842ea 100644 --- a/substrate/frame/child-bounties/src/tests.rs +++ b/substrate/frame/child-bounties/src/tests.rs @@ -111,6 +111,8 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type MaxQueuedSpends = ConstU32<100>; + type OrderExpirationPeriod = ConstU64<10>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/substrate/frame/staking-async/runtimes/parachain/src/governance/mod.rs b/substrate/frame/staking-async/runtimes/parachain/src/governance/mod.rs index 3685e92d2673..57a8b10fa9d6 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/governance/mod.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/governance/mod.rs @@ -168,6 +168,8 @@ impl pallet_treasury::Config for Runtime { >; type PayoutPeriod = PayoutSpendPeriod; type BlockNumberProvider = RelaychainDataProvider; + type MaxQueuedSpends = ConstU32<100>; + type OrderExpirationPeriod = ConstU32<{ 2 * DAYS }>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = polkadot_runtime_common::impls::benchmarks::TreasuryArguments; } diff --git a/substrate/frame/staking-async/runtimes/parachain/src/weights/pallet_treasury.rs b/substrate/frame/staking-async/runtimes/parachain/src/weights/pallet_treasury.rs index 80fd80017608..f1503790dbcf 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/weights/pallet_treasury.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/weights/pallet_treasury.rs @@ -19,7 +19,6 @@ //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev //! DATE: 2023-07-07, STEPS: `50`, REPEAT: `2`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `cob`, CPU: `` //! EXECUTION: None, WASM-EXECUTION: Compiled, CHAIN: Some("rococo-dev"), DB CACHE: 1024 // Executed Command: @@ -163,6 +162,16 @@ impl pallet_treasury::WeightInfo for WeightInfo { .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(2)) } + fn check_status_rotation() -> Weight { + // Proof Size summary in bytes: + // Measured: `442` + // Estimated: `5313` + // Minimum execution time: 245_000_000 picoseconds. + Weight::from_parts(281_000_000, 0) + .saturating_add(Weight::from_parts(0, 5313)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } /// Storage: Treasury Spends (r:1 w:1) /// Proof: Treasury Spends (max_values: None, max_size: Some(1848), added: 4323, mode: MaxEncodedLen) fn void_spend() -> Weight { diff --git a/substrate/frame/staking-async/runtimes/rc/src/lib.rs b/substrate/frame/staking-async/runtimes/rc/src/lib.rs index d840c4e11a64..b31e931a52ff 100644 --- a/substrate/frame/staking-async/runtimes/rc/src/lib.rs +++ b/substrate/frame/staking-async/runtimes/rc/src/lib.rs @@ -1053,6 +1053,8 @@ impl pallet_treasury::Config for Runtime { >; type PayoutPeriod = PayoutSpendPeriod; type BlockNumberProvider = System; + type MaxQueuedSpends = ConstU32<100>; + type OrderExpirationPeriod = ConstU32<{ 2 * DAYS }>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = polkadot_runtime_common::impls::benchmarks::TreasuryArguments; } diff --git a/substrate/frame/staking-async/runtimes/rc/src/weights/pallet_treasury.rs b/substrate/frame/staking-async/runtimes/rc/src/weights/pallet_treasury.rs index 2ff1b7ec304f..0f84034e55bb 100644 --- a/substrate/frame/staking-async/runtimes/rc/src/weights/pallet_treasury.rs +++ b/substrate/frame/staking-async/runtimes/rc/src/weights/pallet_treasury.rs @@ -19,7 +19,6 @@ //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 //! DATE: 2025-02-21, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `3a2e9ae8a8f5`, CPU: `Intel(R) Xeon(R) CPU @ 2.60GHz` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: 1024 // Executed Command: @@ -154,6 +153,16 @@ impl pallet_treasury::WeightInfo for WeightInfo { .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(2)) } + fn check_status_rotation() -> Weight { + // Proof Size summary in bytes: + // Measured: `305` + // Estimated: `5318` + // Minimum execution time: 28_594_000 picoseconds. + Weight::from_parts(29_512_000, 0) + .saturating_add(Weight::from_parts(0, 5318)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } /// Storage: `Treasury::Spends` (r:1 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(1853), added: 4328, mode: `MaxEncodedLen`) fn void_spend() -> Weight { diff --git a/substrate/frame/tips/src/tests.rs b/substrate/frame/tips/src/tests.rs index 47388a108622..f0446a5e4145 100644 --- a/substrate/frame/tips/src/tests.rs +++ b/substrate/frame/tips/src/tests.rs @@ -120,6 +120,8 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type MaxQueuedSpends = ConstU32<100>; + type OrderExpirationPeriod = ConstU64<10>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } @@ -143,6 +145,8 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type MaxQueuedSpends = ConstU32<100>; + type OrderExpirationPeriod = ConstU64<10>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/substrate/frame/treasury/src/benchmarking.rs b/substrate/frame/treasury/src/benchmarking.rs index a11723a27b2c..c1e9d9425db4 100644 --- a/substrate/frame/treasury/src/benchmarking.rs +++ b/substrate/frame/treasury/src/benchmarking.rs @@ -100,6 +100,20 @@ fn assert_last_event, I: 'static>(generic_event: >:: frame_system::Pallet::::assert_last_event(generic_event.into()); } +// Fill the payout queue for `asset_kind` with `n` entries so the benchmarked call hits the +// worst case: a full scan over (and re-encode of) a queue of up to `MaxQueuedSpends` entries. +// The seeded indices are offset to avoid colliding with spends created by the benchmark and +// use an order key of zero so that a subsequent sorted insert scans the entire queue. +fn fill_payout_queue, I: 'static>(asset_kind: T::AssetKind, n: u32) { + let order_key = BlockNumberFor::::zero(); + let queue: BoundedVec<_, T::MaxQueuedSpends> = (0..n) + .map(|i| (i + 1_000_000, order_key)) + .collect::>() + .try_into() + .expect("n is at most MaxQueuedSpends; qed"); + PayoutQueue::::insert(asset_kind, queue); +} + // Create the arguments for the `spend` dispatchable. fn create_spend_arguments, I: 'static>( seed: u32, @@ -189,6 +203,17 @@ mod benchmarks { create_spend_arguments::(SEED); T::BalanceConverter::ensure_successful(asset_kind.clone()); + // Worst case: `NextPayout` is already occupied by a later-maturing head, so the new spend + // (with `valid_from = now`) preempts it. The demoted head is re-inserted into an almost + // full payout queue, scanning all existing entries, and the head is rewritten. + let now = T::BlockNumberProvider::current_block_number(); + let head_order_key = now.saturating_add(T::OrderExpirationPeriod::get()); + NextPayout::::insert( + asset_kind.clone(), + (1_000_000u32, head_order_key, head_order_key), + ); + fill_payout_queue::(asset_kind.clone(), T::MaxQueuedSpends::get().saturating_sub(1)); + #[extrinsic_call] _( origin as T::RuntimeOrigin, @@ -294,6 +319,10 @@ mod benchmarks { false }; + // Worst case: removing the spend promotes the next entry from a full payout queue. + let (asset_kind, ..) = create_spend_arguments::(SEED); + fill_payout_queue::(asset_kind, T::MaxQueuedSpends::get()); + #[block] { let res = @@ -313,6 +342,53 @@ mod benchmarks { Ok(()) } + // The rotation branch of `check_status`: a head whose payout order has expired is rotated to + // the back of a (near) full queue and the front entry is promoted. Benchmarked separately so + // `check_status`'s declared weight can be `check_status().max(check_status_rotation())`. + #[benchmark] + fn check_status_rotation() -> Result<(), BenchmarkError> { + let origin = + T::SpendOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + let (asset_kind, amount, _, beneficiary_lookup) = create_spend_arguments::(SEED); + T::BalanceConverter::ensure_successful(asset_kind.clone()); + let caller: T::AccountId = account("caller", 0, SEED); + + // The spend becomes NextPayout (queue empty), status Pending. + Treasury::::spend( + origin, + Box::new(asset_kind.clone()), + amount, + Box::new(beneficiary_lookup), + None, + )?; + + // Force the head's payout order to be already expired (order `expire_at` of zero is `< + // now`) while its spend payout window stays open, so `check_status` takes the rotation + // branch. + NextPayout::::mutate(&asset_kind, |entry| { + if let Some((_, _, expire_at)) = entry { + *expire_at = BlockNumberFor::::zero(); + } + }); + // Relay-block providers (e.g. asset-hub) report 0 at genesis; advance so the zeroed order + // expiration is in the past. + T::BlockNumberProvider::set_block_number(One::one()); + + // Worst case: a near-full queue, so rotation scans it, re-inserts the expired head, and + // promotes the front. Leave one slot free for the re-insert. + fill_payout_queue::(asset_kind.clone(), T::MaxQueuedSpends::get().saturating_sub(1)); + + #[block] + { + assert_ok!(Treasury::::check_status(RawOrigin::Signed(caller).into(), 0u32)); + } + + // The expired head (index 0) was rotated to the back; a queued spend is now NextPayout. + assert!(matches!(NextPayout::::get(&asset_kind), Some((idx, _, _)) if idx != 0)); + assert_last_event::(Event::PayoutQueueRotated { asset_kind, index: 0 }.into()); + Ok(()) + } + #[benchmark] fn void_spend() -> Result<(), BenchmarkError> { let (asset_kind, amount, _, beneficiary_lookup) = create_spend_arguments::(SEED); @@ -335,6 +411,9 @@ mod benchmarks { let origin = T::RejectOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + // Worst case: voiding the spend promotes the next entry from a full payout queue. + fill_payout_queue::(asset_kind.clone(), T::MaxQueuedSpends::get()); + #[block] { let res = Treasury::::void_spend(origin as T::RuntimeOrigin, 0u32); diff --git a/substrate/frame/treasury/src/lib.rs b/substrate/frame/treasury/src/lib.rs index 91c58e14e52e..68d162951454 100644 --- a/substrate/frame/treasury/src/lib.rs +++ b/substrate/frame/treasury/src/lib.rs @@ -67,6 +67,27 @@ //! [`pallet::Config::Paymaster`]. To claim these spends, the `payout` dispatchable should be called //! within some temporal bounds, starting from the moment they become valid and within one //! [`pallet::Config::PayoutPeriod`]. +//! +//! ## FIFO Payout Queue +//! +//! The treasury implements a FIFO (First-In-First-Out) payout queue per asset kind to ensure that +//! spends are paid out in the order they were approved. This prevents smaller, later-approved +//! spends from draining the treasury before larger, earlier-approved spends can be paid. +//! +//! When a spend is approved via the `spend` call, it is automatically inserted into the payout +//! queue for its asset kind, sorted by an order key — `max(now, valid_from)` for newly +//! inserted spends, so a later approval never overtakes an already-mature spend. Spends are then +//! paid out in order via the `payout` call, which only processes the spend at the head of the +//! queue for that asset kind. +//! +//! If a spend at the head of the queue cannot be paid out (e.g., due to insufficient funds), +//! its order will expire after [`Config::OrderExpirationPeriod`] blocks. The `check_status` call +//! will then rotate it back into the queue with the current block number as its order key, +//! placing it behind every already-mature spend and allowing other spends of the same asset kind +//! to be processed. +//! +//! Payout ordering is managed independently per asset kind - a spend of one asset cannot block +//! payouts of a different asset. #![cfg_attr(not(feature = "std"), no_std)] @@ -210,7 +231,11 @@ pub mod pallet { }; use frame_system::pallet_prelude::{ensure_signed, OriginFor}; + /// The in-code storage version. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(PhantomData<(T, I)>); #[pallet::config] @@ -262,7 +287,7 @@ pub mod pallet { type SpendOrigin: EnsureOrigin>; /// Type parameter representing the asset kinds to be spent from the treasury. - type AssetKind: Parameter + MaxEncodedLen; + type AssetKind: Parameter + MaxEncodedLen + Clone; /// Type parameter used to identify the beneficiaries eligible to receive treasury spends. type Beneficiary: Parameter + MaxEncodedLen; @@ -286,6 +311,14 @@ pub mod pallet { #[pallet::constant] type PayoutPeriod: Get>; + /// Maximum number of spends in the payout queue per asset kind. + #[pallet::constant] + type MaxQueuedSpends: Get; + + /// Period after which a spend's order expires and can be moved to the end of the queue. + #[pallet::constant] + type OrderExpirationPeriod: Get>; + /// Helper type for benchmarks. #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper: ArgumentsFactory; @@ -360,6 +393,34 @@ pub mod pallet { #[pallet::storage] pub type LastSpendPeriod = StorageValue<_, BlockNumberFor, OptionQuery>; + /// The next spend to be paid out for each asset kind, as `(spend_index, order_key, + /// expire_at)`. `order_key` is the head's order key (`max(now, valid_from)` for a freshly + /// inserted spend, or the rotation block for a rotated one) and is used to keep the head ahead + /// of every queued spend. `expire_at` is the block at which its position at the head expires + /// and it can be rotated to the back. + #[pallet::storage] + pub type NextPayout, I: 'static = ()> = StorageMap< + _, + Twox64Concat, + T::AssetKind, + (SpendIndex, BlockNumberFor, BlockNumberFor), + OptionQuery, + >; + + /// The queue of spends in payout order for each asset kind. + /// + /// Each entry contains `(spend_index, order_key)` and the queue is sorted by the order key. + /// The order key is `max(now, valid_from)` for newly inserted spends and the block number + /// of the rotation for spends that were rotated to the back after their order expired. + #[pallet::storage] + pub type PayoutQueue, I: 'static = ()> = StorageMap< + _, + Twox64Concat, + T::AssetKind, + BoundedVec<(SpendIndex, BlockNumberFor), T::MaxQueuedSpends>, + ValueQuery, + >; + #[pallet::genesis_config] #[derive(frame_support::DefaultNoBound)] pub struct GenesisConfig, I: 'static = ()> { @@ -418,6 +479,8 @@ pub mod pallet { /// A spend was processed and removed from the storage. It might have been successfully /// paid or it may have expired. SpendProcessed { index: SpendIndex }, + /// The payout queue was rotated for an asset kind due to order expiration. + PayoutQueueRotated { asset_kind: T::AssetKind, index: SpendIndex }, } /// Error for the treasury pallet. @@ -446,6 +509,10 @@ pub mod pallet { NotAttempted, /// The payment has neither failed nor succeeded yet. Inconclusive, + /// Spend is not the next in the payout queue for its asset kind. + NotNextPayout, + /// Payout queue is full for this asset kind. + QueueFull, } #[pallet::hooks] @@ -634,6 +701,11 @@ pub mod pallet { /// designated beneficiary. The spend must be claimed using the `payout` dispatchable within /// the [`Config::PayoutPeriod`]. /// + /// The spend is automatically inserted into the payout queue for its asset kind in sorted + /// order by its order key (`max(now, valid_from)` for new spends). If there is no current + /// `NextPayout` for this asset kind, the spend becomes the `NextPayout` with an + /// expiration of `max(now, valid_from) + OrderExpirationPeriod`. + /// /// ### Parameters /// - `asset_kind`: An indicator of the specific asset class to be spent. /// - `amount`: The amount to be transferred from the treasury to the `beneficiary`. @@ -701,6 +773,9 @@ pub mod pallet { ); SpendCount::::put(index + 1); + // Insert into payout queue for this asset kind + Self::insert_into_payout_queue(*asset_kind.clone(), index, valid_from)?; + Self::deposit_event(Event::AssetSpendApproved { index, asset_kind: *asset_kind, @@ -725,6 +800,9 @@ pub mod pallet { /// In case of a payout failure, the spend status must be updated with the `check_status` /// dispatchable before retrying with the current function. /// + /// Only the spend designated as `NextPayout` for its asset kind can be claimed. This + /// ensures FIFO ordering of spends per asset kind. + /// /// ### Parameters /// - `index`: The spend index. /// @@ -735,7 +813,15 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::payout())] pub fn payout(origin: OriginFor, index: SpendIndex) -> DispatchResult { ensure_signed(origin)?; - let mut spend = Spends::::get(index).ok_or(Error::::InvalidIndex)?; + + let spend = Spends::::get(index).ok_or(Error::::InvalidIndex)?; + let asset_kind = spend.asset_kind.clone(); + + // Ensure this is the next payout for its asset kind + let next_payout = + NextPayout::::get(&asset_kind).ok_or(Error::::NotNextPayout)?; + ensure!(next_payout.0 == index, Error::::NotNextPayout); + let now = T::BlockNumberProvider::current_block_number(); ensure!(now >= spend.valid_from, Error::::EarlyPayout); ensure!(spend.expire_at > now, Error::::SpendExpired); @@ -747,9 +833,11 @@ pub mod pallet { let id = T::Paymaster::pay(&spend.beneficiary, spend.asset_kind.clone(), spend.amount) .map_err(|_| Error::::PayoutError)?; - spend.status = PaymentState::Attempted { id }; - spend.expire_at = now.saturating_add(T::PayoutPeriod::get()); - Spends::::insert(index, spend); + // Update spend status + let mut updated_spend = spend; + updated_spend.status = PaymentState::Attempted { id }; + updated_spend.expire_at = now.saturating_add(T::PayoutPeriod::get()); + Spends::::insert(index, updated_spend); Self::deposit_event(Event::::Paid { index, payment_id: id }); @@ -768,6 +856,12 @@ pub mod pallet { /// If a spend has either succeeded or expired, it is removed from the storage by this /// function. In such instances, transaction fees are refunded. /// + /// This function also manages the payout order: + /// - If the `NextPayout` order has expired and the spend is still Pending/Failed, it is + /// rotated to the back of the queue. + /// - If a spend has been successfully paid out or has fully expired, it is removed from the + /// queue and the next entry is promoted. + /// /// ### Parameters /// - `index`: The spend index. /// @@ -775,23 +869,43 @@ pub mod pallet { /// /// Emits [`Event::PaymentFailed`] if the spend payout has failed. /// Emits [`Event::SpendProcessed`] if the spend payout has succeed. + /// Emits [`Event::PayoutQueueRotated`] if the queue was rotated due to expiration. #[pallet::call_index(7)] - #[pallet::weight(T::WeightInfo::check_status())] + #[pallet::weight(T::WeightInfo::check_status().max(T::WeightInfo::check_status_rotation()))] pub fn check_status(origin: OriginFor, index: SpendIndex) -> DispatchResultWithPostInfo { use PaymentState as State; use PaymentStatus as Status; ensure_signed(origin)?; - let mut spend = Spends::::get(index).ok_or(Error::::InvalidIndex)?; + let spend = Spends::::get(index).ok_or(Error::::InvalidIndex)?; + let asset_kind = spend.asset_kind.clone(); let now = T::BlockNumberProvider::current_block_number(); + // Handle spend expiration (not order expiration) if now > spend.expire_at && !matches!(spend.status, State::Attempted { .. }) { - // spend has expired and no further status update is expected. + // Spend has expired and no further status update is expected. + Self::remove_from_queue(&asset_kind, index); Spends::::remove(index); Self::deposit_event(Event::::SpendProcessed { index }); return Ok(Pays::No.into()); } + // Check if this spend is the NextPayout and if its order has expired + if let Some((next_index, _, expire_at)) = NextPayout::::get(&asset_kind) { + if next_index == index && expire_at < now { + // Order has expired - rotate the queue if spend is still Pending/Failed + if matches!(spend.status, State::Pending | State::Failed) { + Self::rotate_payout_queue(&asset_kind)?; + Self::deposit_event(Event::::PayoutQueueRotated { + asset_kind: asset_kind.clone(), + index, + }); + // Return early - the spend was rotated, not processed + return Ok(Pays::No.into()); + } + } + } + let payment_id = match spend.status { State::Attempted { id } => id, _ => return Err(Error::::NotAttempted.into()), @@ -799,11 +913,13 @@ pub mod pallet { match T::Paymaster::check_payment(payment_id) { Status::Failure => { - spend.status = PaymentState::Failed; - Spends::::insert(index, spend); + let mut updated_spend = spend; + updated_spend.status = PaymentState::Failed; + Spends::::insert(index, updated_spend); Self::deposit_event(Event::::PaymentFailed { index, payment_id }); }, Status::Success | Status::Unknown => { + Self::remove_from_queue(&asset_kind, index); Spends::::remove(index); Self::deposit_event(Event::::SpendProcessed { index }); return Ok(Pays::No.into()); @@ -822,6 +938,7 @@ pub mod pallet { /// ## Details /// /// A spend void is only possible if the payout has not been attempted yet. + /// The spend is also removed from the payout queue for its asset kind. /// /// ### Parameters /// - `index`: The spend index. @@ -839,6 +956,8 @@ pub mod pallet { Error::::AlreadyAttempted ); + // let asset_kind = spend.asset_kind.clone(); + Self::remove_from_queue(&spend.asset_kind, index); Spends::::remove(index); Self::deposit_event(Event::::AssetSpendVoided { index }); Ok(()) @@ -903,6 +1022,141 @@ impl, I: 'static> Pallet { Approvals::::get() } + /// Insert a newly approved spend into the payout order for the given asset kind. For newly + /// inserted spends the order key is `max(now, valid_from)` — `valid_from` clamped to the + /// approval block, so a later approval cannot overtake an already-mature spend. + /// + /// The head ([`NextPayout`]) is always the earliest-maturing spend: + /// - if there is no head yet, the spend becomes the head; + /// - if the spend matures strictly earlier than the current head, it preempts it: the current + /// head is demoted into the queue (kept sorted by its own order key) and the new spend + /// becomes the head; + /// - otherwise the spend is inserted into the queue in sorted order. + fn insert_into_payout_queue( + asset_kind: T::AssetKind, + index: SpendIndex, + valid_from: BlockNumberFor, + ) -> DispatchResult { + let now = T::BlockNumberProvider::current_block_number(); + // The order key is clamped to `now` so that a spend approved later never overtakes an + // already-mature spend just because its `valid_from` lies further in the past. Spends that + // are already mature on approval are ordered by approval time, while not-yet-mature spends + // are ordered by their maturity (`valid_from`). + let order_key = now.max(valid_from); + // The order only starts counting down once the spend is mature. + let expire_at = order_key.saturating_add(T::OrderExpirationPeriod::get()); + + match NextPayout::::get(&asset_kind) { + // No head for this asset kind yet, set this spend as the head. + None => { + NextPayout::::insert(&asset_kind, (index, order_key, expire_at)); + }, + // The new spend matures strictly earlier than the current head: it preempts the head. + // Demote the current head into the queue (keeping it sorted by its own order key) and + // install the new spend as the head. + Some((head_index, head_order_key, _)) if order_key < head_order_key => { + let mut queue = PayoutQueue::::get(&asset_kind); + // Insert the demoted head ahead of queue entries that share its order key: it was + // approved before them, so it keeps its place among equal-maturity spends. + let insert_pos = queue + .iter() + .position(|(_, order_key)| *order_key >= head_order_key) + .unwrap_or(queue.len()); + queue + .try_insert(insert_pos, (head_index, head_order_key)) + .map_err(|_| Error::::QueueFull)?; + PayoutQueue::::insert(&asset_kind, queue); + NextPayout::::insert(&asset_kind, (index, order_key, expire_at)); + }, + // The head already matures no later than the new spend, add it to the queue in sorted + // order. + Some(_) => { + let mut queue = PayoutQueue::::get(&asset_kind); + let insert_pos = queue + .iter() + .position(|(_, queue_order_key)| *queue_order_key > order_key) + .unwrap_or(queue.len()); + queue + .try_insert(insert_pos, (index, order_key)) + .map_err(|_| Error::::QueueFull)?; + PayoutQueue::::insert(&asset_kind, queue); + }, + } + + Ok(()) + } + + /// Remove a spend from the payout queue for the given asset kind and update NextPayout. + /// Called when a spend is successfully paid out, expired, or voided. + fn remove_from_queue(asset_kind: &T::AssetKind, index: SpendIndex) { + if let Some((next_index, _, _)) = NextPayout::::get(asset_kind) { + if next_index == index { + // This was the next payout, promote the next one from the queue + let mut queue = PayoutQueue::::get(asset_kind); + + // Check what to promote BEFORE removing + if let Some(&(new_next_index, order_key)) = queue.first() { + // Promote to NextPayout. The order only starts counting down once the + // promoted spend is mature (its order key is `valid_from` for fresh entries + // and is never in the future for rotated ones). + let now = T::BlockNumberProvider::current_block_number(); + let new_expire_at = + now.max(order_key).saturating_add(T::OrderExpirationPeriod::get()); + NextPayout::::insert( + asset_kind, + (new_next_index, order_key, new_expire_at), + ); + + // Remove it from queue + queue.remove(0); + PayoutQueue::::insert(asset_kind, queue); + } else { + // Queue is empty, clear NextPayout + NextPayout::::remove(asset_kind); + } + } else { + // This spend is in the queue but not at the head + let mut queue = PayoutQueue::::get(asset_kind); + if let Some(pos) = queue.iter().position(|(idx, _)| *idx == index) { + queue.remove(pos); + PayoutQueue::::insert(asset_kind, queue); + } + } + } + } + + /// Rotate the payout queue for the given asset kind when the current order expires. + /// Re-inserts the current NextPayout into the queue with an order key of `now`, keeping the + /// queue sorted by order key, and promotes the next entry. + fn rotate_payout_queue(asset_kind: &T::AssetKind) -> DispatchResult { + let (current_index, _, _) = + NextPayout::::get(asset_kind).ok_or(Error::::NotNextPayout)?; + let now = T::BlockNumberProvider::current_block_number(); + + // Rotation is size-neutral: it re-inserts the expired head and promotes one entry from the + // front, so the queue length never changes. + let mut queue = PayoutQueue::::get(asset_kind).into_inner(); + + // Re-insert the expired spend with `now` as its order key, keeping the queue sorted. + // Using `now` (rather than the spend's `valid_from`) sends the rotated spend behind + // every entry that is already mature, while preserving the sort order. + let insert_pos = + queue.iter().position(|(_, order_key)| *order_key > now).unwrap_or(queue.len()); + queue.insert(insert_pos, (current_index, now)); + + // Promote the front to NextPayout and remove it. The queue is guaranteed non-empty here + // because we just inserted into it, so there is always something to promote. + let (new_next, order_key) = queue.remove(0); + let new_expire_at = now.max(order_key).saturating_add(T::OrderExpirationPeriod::get()); + NextPayout::::insert(asset_kind, (new_next, order_key, new_expire_at)); + + // Net length is unchanged (one inserted, one removed), so it still fits the bound. + let queue = BoundedVec::<_, T::MaxQueuedSpends>::try_from(queue) + .map_err(|_| Error::::QueueFull)?; + PayoutQueue::::insert(asset_kind, queue); + Ok(()) + } + /// Spend some money! returns number of approvals before spend. pub fn spend_funds( spend_periods_passed: BlockNumberFor, @@ -1007,6 +1261,7 @@ impl, I: 'static> Pallet { fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { Self::try_state_proposals()?; Self::try_state_spends()?; + Self::try_state_payout_queue()?; Ok(()) } @@ -1080,6 +1335,74 @@ impl, I: 'static> Pallet { Ok(()) } + + // ## Invariants of payout queue storage items + // + // 1. The length of each [`PayoutQueue`] must not exceed [`Config::MaxQueuedSpends`]. + // 2. No duplicate spend indices in any [`PayoutQueue`]. + // 3. The queue for each asset kind must be sorted by order key. The order key is + // `valid_from` for newly inserted spends and the block number of the rotation for rotated + // spends, so the sort order always holds. + // 4. If [`NextPayout`] for an asset kind is Some, its spend index must not also be present + // in the [`PayoutQueue`] for that asset kind. + // 5. All spend indices in [`PayoutQueue`] for an asset kind must exist in [`Spends`] with + // Pending or Failed status and a matching asset kind. + #[cfg(any(feature = "try-runtime", test))] + fn try_state_payout_queue() -> Result<(), sp_runtime::TryRuntimeError> { + use alloc::collections::btree_set::BTreeSet; + + for (asset_kind, queue) in PayoutQueue::::iter() { + ensure!( + queue.len() as u32 <= T::MaxQueuedSpends::get(), + "Payout queue length exceeds MaxQueuedSpends." + ); + + // Check no duplicates + let unique_indices: BTreeSet<_> = queue.iter().map(|(idx, _)| *idx).collect(); + ensure!( + unique_indices.len() == queue.len(), + "Payout queue contains duplicate spend indices." + ); + + // Check sorted by order key + let mut prev_order_key: Option> = None; + for (_, order_key) in queue.iter() { + if let Some(prev) = prev_order_key { + ensure!(prev <= *order_key, "Payout queue is not sorted by order key."); + } + prev_order_key = Some(*order_key); + } + + // Check that NextPayout is not also in the queue (they're separate) + if let Some((next_index, _, _)) = NextPayout::::get(&asset_kind) { + ensure!( + !queue.iter().any(|(idx, _)| *idx == next_index), + "NextPayout should not be in PayoutQueue." + ); + } + + // Every queued spend is awaiting payout. In steady state that means `Pending` or + // `Failed`, but the migration can also seed an in-flight `Attempted` spend into the + // queue (so a later payment failure can still be retried rather than lost), which + // `check_status` then resolves. + for (spend_index, _) in queue.iter() { + let spend = Spends::::get(spend_index).ok_or( + sp_runtime::TryRuntimeError::Other("Spend in queue must exist in Spends."), + )?; + ensure!( + matches!( + spend.status, + PaymentState::Pending | + PaymentState::Failed | PaymentState::Attempted { .. } + ), + "Spend in queue must have Pending, Failed or Attempted status." + ); + // Verify asset kind matches + ensure!(spend.asset_kind == asset_kind, "Spend in queue has wrong asset kind."); + } + } + Ok(()) + } } impl, I: 'static> OnUnbalanced> for Pallet { diff --git a/substrate/frame/treasury/src/migration.rs b/substrate/frame/treasury/src/migration.rs index 7c8c587f1664..cb72680b5622 100644 --- a/substrate/frame/treasury/src/migration.rs +++ b/substrate/frame/treasury/src/migration.rs @@ -18,16 +18,17 @@ //! Treasury pallet migrations. use super::*; -use alloc::collections::BTreeSet; -#[cfg(feature = "try-runtime")] -use alloc::vec::Vec; +use alloc::{collections::BTreeSet, vec::Vec}; use core::marker::PhantomData; -use frame_support::{defensive, traits::OnRuntimeUpgrade}; +use frame_support::{ + defensive, + traits::{OnRuntimeUpgrade, UncheckedOnRuntimeUpgrade}, +}; /// The log target for this pallet. const LOG_TARGET: &str = "runtime::treasury"; -pub mod cleanup_proposals { +mod cleanup_proposals { use super::*; /// Migration to cleanup unapproved proposals to return the bonds back to the proposers. @@ -133,3 +134,550 @@ pub mod cleanup_proposals { } } } + +/// Migration to initialize the payout queue for existing spends (Solution 1.2). +/// +/// Collects every live spend (dropping only `Pending`/`Failed` spends whose payout window has +/// already expired, and keeping in-flight `Attempted` ones), groups them by asset kind, sorts each +/// group by order key `max(now, valid_from)` then by index, and initializes `NextPayout` and +/// `PayoutQueue` for each asset kind. +mod migrate_to_ordered_payouts { + use super::*; + + /// Migration to initialize the payout queue for existing spends. + /// + /// Wrapped in [`MigrateToOrderedPayouts`](super::MigrateToOrderedPayouts) for the storage + /// version check. + pub struct UncheckedMigrateToOrderedPayouts(PhantomData<(T, I)>); + + impl, I: 'static> UncheckedOnRuntimeUpgrade + for UncheckedMigrateToOrderedPayouts + { + fn on_runtime_upgrade() -> Weight { + log::info!( + target: LOG_TARGET, + "Running migration to initialize ordered payouts", + ); + + let now = T::BlockNumberProvider::current_block_number(); + let mut total_spends_read: u64 = 0; + + // Collect all pending/failed spends that haven't expired. + let mut spends_vec: Vec<(T::AssetKind, SpendIndex, BlockNumberFor)> = Vec::new(); + + for (index, spend) in Spends::::iter() { + total_spends_read += 1; + match spend.status { + // Expired `Pending`/`Failed` spends are dropped, matching `check_status`, which + // removes them once `now > expire_at`. + PaymentState::Pending | PaymentState::Failed if spend.expire_at <= now => { + log::debug!( + target: LOG_TARGET, + "Skipping expired spend {} (expire_at: {:?}, now: {:?})", + index, + spend.expire_at, + now, + ); + }, + // Every other live spend is ordered, including in-flight `Attempted` ones. An + // `Attempted` spend whose payment later fails would otherwise be left out of + // the payout order and could never be retried (lost); keeping it ordered + // lets `check_status` resolve it and, on failure, promote/retry it. This + // mirrors `check_status`, which never drops an `Attempted` spend on + // expiration. + _ => { + spends_vec.push((spend.asset_kind, index, spend.valid_from)); + }, + } + } + + // Group by encoded AssetKind + let mut spends_by_asset: BTreeMap< + Vec, + (T::AssetKind, Vec<(SpendIndex, BlockNumberFor)>), + > = BTreeMap::new(); + + for (asset_kind, index, valid_from) in spends_vec { + let key = asset_kind.encode(); + spends_by_asset + .entry(key) + .or_insert_with(|| (asset_kind, Vec::new())) + .1 + .push((index, valid_from)); + } + + let mut total_spends_processed = 0u32; + let mut total_assets_processed = 0u32; + + // Process each AssetKind + for (_, (asset_kind, mut spends)) in spends_by_asset { + // Sort by the clamped order key (`max(now, valid_from)`), then by index for + // deterministic ordering (consensus safety). Clamping is what keeps spends already + // mature at migration ordered by approval (index) rather than by a back-dated + // `valid_from`, matching the runtime insertion rule; not-yet-mature spends still + // order by their maturity. + spends.sort_by(|a, b| now.max(a.1).cmp(&now.max(b.1)).then_with(|| a.0.cmp(&b.0))); + + let spend_count = spends.len() as u32; + total_spends_processed += spend_count; + total_assets_processed += 1; + + log::info!( + target: LOG_TARGET, + "Processing asset kind with {} spends", + spend_count, + ); + + // Build the payout queue (bounded by MaxQueuedSpends) + let mut queue = + BoundedVec::<(SpendIndex, BlockNumberFor), T::MaxQueuedSpends>::default(); + let mut next_payout_set = false; + + for (index, valid_from) in spends { + // The order key is clamped to `now` so that spends already mature at migration + // are ordered by approval (index), while not-yet-mature spends keep their + // maturity order. Clamping is monotonic, so the pre-sorted order is preserved. + let order_key = now.max(valid_from); + if !next_payout_set { + // First spend (earliest-maturing) becomes NextPayout. + let expire_at = order_key.saturating_add(T::OrderExpirationPeriod::get()); + NextPayout::::insert(&asset_kind, (index, order_key, expire_at)); + next_payout_set = true; + log::debug!( + target: LOG_TARGET, + "Set NextPayout for asset to {} with expiration at {:?}", + index, + expire_at, + ); + } else if queue.len() < T::MaxQueuedSpends::get() as usize { + // Add to queue + if let Err(_) = queue.try_push((index, order_key)) { + log::warn!( + target: LOG_TARGET, + "Failed to push spend {} to queue (queue full)", + index + ); + } + } else { + log::warn!( + target: LOG_TARGET, + "Payout queue is full, skipping spend {}", + index + ); + } + } + + PayoutQueue::::insert(&asset_kind, queue); + } + + log::info!( + target: LOG_TARGET, + "Migration complete: processed {} spends across {} asset kinds", + total_spends_processed, + total_assets_processed, + ); + + let reads = total_spends_read + 1; + let writes = total_assets_processed as u64 * 2; + T::DbWeight::get().reads_writes(reads, writes) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + let now = T::BlockNumberProvider::current_block_number(); + + // Spends the migration will order, using the same inclusion rule as + // `on_runtime_upgrade` (every live spend, plus in-flight `Attempted` ones regardless + // of expiration), and the per-asset counts. + let mut migrated: Vec<(T::AssetKind, SpendIndex)> = Vec::new(); + let mut per_asset: BTreeMap, u32> = BTreeMap::new(); + for (index, spend) in Spends::::iter() { + let include = match spend.status { + PaymentState::Pending | PaymentState::Failed => spend.expire_at > now, + PaymentState::Attempted { .. } => true, + }; + if include { + *per_asset.entry(spend.asset_kind.encode()).or_default() += 1; + migrated.push((spend.asset_kind, index)); + } + } + + // The migration places one spend at `NextPayout` and the rest in the queue (bounded by + // `MaxQueuedSpends`). If an asset has more live spends than that capacity, the + // migration would silently drop the overflow — fail here so the runtime raises + // `MaxQueuedSpends` before deploying instead. + let capacity = T::MaxQueuedSpends::get().saturating_add(1); + for (_, count) in per_asset.iter() { + ensure!( + *count <= capacity, + "MaxQueuedSpends too small: an asset has more live spends than NextPayout + queue can hold." + ); + } + + log::info!( + target: LOG_TARGET, + "Pre-upgrade: {} spends to order across {} asset kinds", + migrated.len(), + per_asset.len(), + ); + + Ok(migrated.encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + let pre_migrated: Vec<(T::AssetKind, SpendIndex)> = + Vec::decode(&mut &state[..]).expect("Known good"); + + // Every spend the migration intended to order must land in exactly one of `NextPayout` + // or `PayoutQueue` — nothing silently dropped (the `pre_upgrade` capacity check + // guarantees this; verify it here). + let mut post_ordered_count = 0usize; + for (_, queue) in PayoutQueue::::iter() { + post_ordered_count += queue.len(); + } + post_ordered_count += NextPayout::::iter().count(); + + log::info!( + target: LOG_TARGET, + "Post-upgrade: {} spends ordered (pre-upgrade had {} to order)", + post_ordered_count, + pre_migrated.len() + ); + ensure!( + post_ordered_count == pre_migrated.len(), + "Migration dropped spends: ordered count does not match pre-upgrade count." + ); + + // Verify queue invariants for each asset kind. + for (asset_kind, queue) in PayoutQueue::::iter() { + ensure!( + queue.len() as u32 <= T::MaxQueuedSpends::get(), + "Queue length exceeds MaxQueuedSpends" + ); + + // A queued spend is awaiting payout, so it may be `Pending`, `Failed`, or an + // in-flight `Attempted` carried over by the migration. + for (index, _) in queue.iter() { + let spend = Spends::::get(index) + .ok_or(sp_runtime::TryRuntimeError::Other("Spend in queue not found"))?; + ensure!( + matches!( + spend.status, + PaymentState::Pending | + PaymentState::Failed | PaymentState::Attempted { .. } + ), + "Spend in queue has invalid status" + ); + ensure!(spend.asset_kind == asset_kind, "Spend in queue has wrong asset kind"); + } + + // Verify NextPayout is NOT in queue + if let Some((next_index, _, _)) = NextPayout::::get(&asset_kind) { + ensure!( + !queue.iter().any(|(idx, _)| *idx == next_index), + "NextPayout should not be in the queue" + ); + } + } + + Ok(()) + } + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::{ + pallet::Spends, + tests::{ExtBuilder, System, Test}, + }; + use frame_support::traits::{OnRuntimeUpgrade, StorageVersion, UncheckedOnRuntimeUpgrade}; + + #[cfg(feature = "try-runtime")] + use frame_support::assert_ok; + + /// Helper to directly insert a spend into storage + fn insert_spend( + index: SpendIndex, + asset_kind: u32, + amount: u64, + beneficiary: u128, + valid_from: u64, + expire_at: u64, + status: PaymentState, + ) { + let spend = crate::SpendStatus { + asset_kind, + amount, + beneficiary, + valid_from, + expire_at, + status, + }; + + crate::pallet::Spends::::insert(index, spend); + + let current = crate::pallet::SpendCount::::get(); + + if index >= current { + crate::pallet::SpendCount::::put(index + 1); + } + } + + #[test] + fn migration_runs_once() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(100); + insert_spend(0, 1, 100, 1000, 50, 200, PaymentState::Pending); + + StorageVersion::new(0).put::>(); + crate::migration::MigrateToOrderedPayouts::::on_runtime_upgrade(); + + assert_eq!(StorageVersion::get::>(), 1); + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + + // Re-run is a no-op: the version gate skips the migration. + NextPayout::::remove(1u32); + crate::migration::MigrateToOrderedPayouts::::on_runtime_upgrade(); + + assert_eq!(StorageVersion::get::>(), 1); + assert!(NextPayout::::get(1u32).is_none()); + }); + } + + #[test] + fn migration_empty_state() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(100); + assert_eq!(Spends::::iter().count(), 0); + + let weight = UncheckedMigrateToOrderedPayouts::::on_runtime_upgrade(); + + assert!(weight.ref_time() == 0); + assert!(NextPayout::::iter().next().is_none()); + assert!(PayoutQueue::::iter().next().is_none()); + }); + } + + #[test] + fn migration_includes_attempted_spends() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(100); + + // An in-flight `Attempted` spend must stay in the payout order so a later payment + // failure can be retried rather than lost. + insert_spend(0, 1, 100, 1000, 50, 200, PaymentState::Attempted { id: 123u64 }); + + UncheckedMigrateToOrderedPayouts::::on_runtime_upgrade(); + + // Only spend → becomes NextPayout (order key = max(now=100, valid_from=50) = 100). + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + assert_eq!(PayoutQueue::::get(1u32).len(), 0); + }); + } + + #[test] + fn migration_skips_expired_spends() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(100); + + // expire_at (99) < now (100) + insert_spend(0, 1, 100, 1000, 50, 99, PaymentState::Pending); + + UncheckedMigrateToOrderedPayouts::::on_runtime_upgrade(); + + assert!(NextPayout::::get(1u32).is_none()); + }); + } + + #[test] + fn migration_groups_by_asset() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(100); + + // Asset 1: 2 spends + insert_spend(0, 1, 100, 1000, 50, 200, PaymentState::Pending); + insert_spend(1, 1, 200, 1001, 60, 200, PaymentState::Pending); + // Asset 2: 1 spend + insert_spend(2, 2, 300, 1002, 40, 200, PaymentState::Failed); + + UncheckedMigrateToOrderedPayouts::::on_runtime_upgrade(); + + // Asset 1: First spend is NextPayout + let (next_idx, _order_key, expire_at) = NextPayout::::get(1u32).unwrap(); + assert_eq!(next_idx, 0); + assert_eq!(expire_at, 102); // 100 + OrderExpirationPeriod(2) + + // Asset 1 queue: spend 1 (mature at now=100, so order key clamps to 100) + assert_eq!(PayoutQueue::::get(1u32), vec![(1, 100)]); + + // Asset 2: Spend 2 is NextPayout + assert_eq!(NextPayout::::get(2u32).map(|(idx, _, _)| idx), Some(2)); + assert_eq!(PayoutQueue::::get(2u32).len(), 0); + }); + } + + #[test] + fn migration_sorts_by_valid_from() { + ExtBuilder::default().build().execute_with(|| { + // Block is before every `valid_from`, so the order-key clamp is a no-op and spends + // order by maturity (`valid_from`). + System::set_block_number(40); + + // Insert out of order + insert_spend(0, 1, 100, 1000, 100, 200, PaymentState::Pending); // latest + insert_spend(1, 1, 100, 1001, 50, 200, PaymentState::Pending); // earliest + insert_spend(2, 1, 100, 1002, 75, 200, PaymentState::Pending); // middle + + UncheckedMigrateToOrderedPayouts::::on_runtime_upgrade(); + + // Sorted: 1 (50), 2 (75), 0 (100) + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + assert_eq!(PayoutQueue::::get(1u32), vec![(2, 75), (0, 100)]); + }); + } + + #[test] + fn migration_tie_breaks_by_index() { + ExtBuilder::default().build().execute_with(|| { + // Block before `valid_from`, so order keys equal `valid_from` and only the + // index tie-break is exercised. + System::set_block_number(40); + + // Same valid_from, different indices (inserted out of order) + insert_spend(5, 1, 100, 1000, 50, 200, PaymentState::Pending); + insert_spend(3, 1, 100, 1001, 50, 200, PaymentState::Pending); + insert_spend(4, 1, 100, 1002, 50, 200, PaymentState::Pending); + + UncheckedMigrateToOrderedPayouts::::on_runtime_upgrade(); + + // Sorted by index: 3, 4, 5 + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(3)); + assert_eq!(PayoutQueue::::get(1u32), vec![(4, 50), (5, 50)]); + }); + } + + #[test] + fn migration_respects_max_queue() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(100); + + // Create 105 spends (MaxQueuedSpends = 100) + for i in 0..105u32 { + insert_spend( + i, + 1, + 100, + 1000 + i as u128, + 50 + i as u64, + 200, + PaymentState::Pending, + ); + } + + UncheckedMigrateToOrderedPayouts::::on_runtime_upgrade(); + + // NextPayout is index 0 + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + + // Queue capped at 100 + let queue = PayoutQueue::::get(1u32); + assert_eq!(queue.len(), 100); + assert_eq!(queue[0].0, 1); + assert_eq!(queue[99].0, 100); + }); + } + + #[test] + fn migration_mixed_statuses() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(100); + + insert_spend(0, 1, 100, 1000, 50, 200, PaymentState::Pending); + insert_spend(1, 1, 100, 1001, 51, 200, PaymentState::Failed); + insert_spend(2, 1, 100, 1002, 52, 200, PaymentState::Attempted { id: 1 }); + insert_spend(3, 1, 100, 1003, 53, 99, PaymentState::Pending); // expired + + UncheckedMigrateToOrderedPayouts::::on_runtime_upgrade(); + + // Pending (0), Failed (1) and the in-flight Attempted (2) are ordered; the expired + // Pending (3) is dropped. All are mature at `now = 100`, so order keys clamp to 100 + // and ties break by index. + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + assert_eq!(PayoutQueue::::get(1u32), vec![(1, 100), (2, 100)]); + }); + } + + #[test] + #[cfg(feature = "try-runtime")] + fn pre_upgrade_captures_state() { + ExtBuilder::default().build().execute_with(|| { + insert_spend(0, 1, 100, 1000, 50, 200, PaymentState::Pending); + insert_spend(1, 1, 100, 1001, 51, 200, PaymentState::Failed); + insert_spend(2, 1, 100, 1002, 52, 200, PaymentState::Attempted { id: 1 }); + + let state = UncheckedMigrateToOrderedPayouts::::pre_upgrade().unwrap(); + let decoded: Vec<(u32, u32)> = Vec::decode(&mut &state[..]).unwrap(); + + // All three are ordered by the migration, including the in-flight Attempted spend. + assert_eq!(decoded.len(), 3); + assert!(decoded.contains(&(1, 0))); + assert!(decoded.contains(&(1, 1))); + assert!(decoded.contains(&(1, 2))); + }); + } + + #[test] + #[cfg(feature = "try-runtime")] + fn post_upgrade_validates() { + ExtBuilder::default().build().execute_with(|| { + let pre_state = { + let mut spends = Vec::new(); + spends.push((1u32, 0u32)); + spends.push((1u32, 1u32)); + spends.encode() + }; + + System::set_block_number(100); + insert_spend(0, 1, 100, 1000, 50, 200, PaymentState::Pending); + insert_spend(1, 1, 100, 1001, 51, 200, PaymentState::Pending); + UncheckedMigrateToOrderedPayouts::::on_runtime_upgrade(); + + assert_ok!(UncheckedMigrateToOrderedPayouts::::post_upgrade(pre_state)); + }); + } + + #[test] + #[cfg(feature = "try-runtime")] + fn post_upgrade_detects_invalid() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(100); + + // Create invalid state: NextPayout in queue + insert_spend(0, 1, 100, 1000, 50, 200, PaymentState::Pending); + NextPayout::::insert(1u32, (0u32, 100u64, 102u64)); + + let bounded_vec: BoundedVec<(u32, u64), crate::tests::MaxQueuedSpends> = + vec![(0u32, 50u64)].try_into().unwrap(); + crate::pallet::PayoutQueue::::insert(1u32, bounded_vec); + + let pre_state = vec![(1u32, 0u32)].encode(); + + assert!(UncheckedMigrateToOrderedPayouts::::post_upgrade(pre_state).is_err()); + }); + } + } +} + +pub use cleanup_proposals::Migration as CleanupProposalsMigration; + +/// Initialize the payout queue for existing spends, gated on storage version 0 and bumping it +/// to 1. +pub type MigrateToOrderedPayouts = frame_support::migrations::VersionedMigration< + 0, + 1, + migrate_to_ordered_payouts::UncheckedMigrateToOrderedPayouts, + Pallet, + ::DbWeight, +>; diff --git a/substrate/frame/treasury/src/tests.rs b/substrate/frame/treasury/src/tests.rs index 41a37cf98918..57aae7354abf 100644 --- a/substrate/frame/treasury/src/tests.rs +++ b/substrate/frame/treasury/src/tests.rs @@ -139,6 +139,8 @@ parameter_types! { pub const TreasuryPalletId: PalletId = PalletId(*b"py/trsry"); pub TreasuryAccount: u128 = Treasury::account_id(); pub const SpendPayoutPeriod: u64 = 5; + pub const MaxQueuedSpends: u32 = 100; + pub const OrderExpirationPeriod: u64 = 2; } pub struct TestSpendOrigin; @@ -195,6 +197,8 @@ impl Config for Test { type Paymaster = TestPay; type BalanceConverter = MulBy>; type PayoutPeriod = SpendPayoutPeriod; + type MaxQueuedSpends = MaxQueuedSpends; + type OrderExpirationPeriod = OrderExpirationPeriod; type BlockNumberProvider = System; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); @@ -655,6 +659,7 @@ fn spend_payout_works() { System::set_block_number(1); // approve a `2` coins spend of asset `1` to beneficiary `6`, the spend valid from now. assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + // spend is automatically added to payout queue // payout the spend. assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); // beneficiary received `2` coins of asset `1`. @@ -743,6 +748,11 @@ fn spend_valid_from_works() { System::set_block_number(2); assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + // Complete spend 0 so spend 1 can become NextPayout + let payment_id = get_payment_id(0).expect("no payment attempt"); + set_status(payment_id, PaymentStatus::Success); + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 0)); + System::set_block_number(5); // spend approved even if `valid_from` in the past since the payout period has not passed. assert_ok!(Treasury::spend( @@ -838,6 +848,10 @@ fn check_status_works() { Error::::Inconclusive ); + // Complete spend 3 so spend 4 can become NextPayout + set_status(payment_id, PaymentStatus::Success); + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 3)); + // spend `4` removed since the payment status is unknown. assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 4)); @@ -1046,3 +1060,652 @@ fn multiple_spend_periods_work() { assert_eq!(LastSpendPeriod::::get(), Some(8)); }); } + +#[test] +fn spend_auto_enqueues_to_payout_queue() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + // Create a spend - should be automatically added to payout queue + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(6), None)); + // Check queue state - spend should be NextPayout since queue was empty + assert_eq!(NextPayout::::get(1u32), Some((0, 1, 3))); // (index, order_key, expire_at = now + OrderExpirationPeriod) + + // Queue should be empty since this was the first spend + assert_eq!(PayoutQueue::::get(1u32), vec![]); + }); +} + +#[test] +fn multiple_spends_auto_enqueue_in_sorted_order() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create multiple spends with different valid_from values + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(100), + Some(5) + )); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 2, + Box::new(200), + Some(3) + )); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(300), + Some(7) + )); + + // Spend 1 has the earliest order key (3), so it preempts the head; spends 0 and 2 sit in + // the queue sorted by order key. + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + + let queue = PayoutQueue::::get(1u32); + assert_eq!(queue, vec![(0, 5), (2, 7)]); + }); +} + +#[test] +fn payout_only_works_for_next_payout_per_asset() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create two spends for same asset + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(7), None)); + + // Payout spend 1 (not next) should fail + assert_noop!( + Treasury::payout(RuntimeOrigin::signed(1), 1), + Error::::NotNextPayout + ); + + // Payout spend 0 (next) should succeed + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + }); +} + +#[test] +fn different_assets_have_independent_queues() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create spends for different assets + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(100), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(2), 2, Box::new(200), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(150), None)); + + // Each asset should have its own NextPayout + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + assert_eq!(NextPayout::::get(2u32).map(|(idx, _, _)| idx), Some(1)); + + // Asset 1 queue should have spend 2 + assert_eq!(PayoutQueue::::get(1u32), vec![(2, 1)]); + + // Asset 2 queue should be empty + assert_eq!(PayoutQueue::::get(2u32), vec![]); + + // Can payout asset 2 even though asset 1 has earlier spends + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 1)); + assert_eq!(paid(200, 2), 2); + }); +} + +#[test] +fn check_status_rotates_expired_order() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create two spends for same asset + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(100), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(200), None)); + + // Verify initial state + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + + // Move past order expiration + System::set_block_number(4); + + // check_status should rotate the queue + let info = Treasury::check_status(RuntimeOrigin::signed(1), 0).unwrap(); + assert_eq!(info.pays_fee, Pays::No); + + // Queue should be rotated: spend 0 moved to back with `now` as its order key + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + assert_eq!(PayoutQueue::::get(1u32), vec![(0, 4)]); + + System::assert_last_event( + Event::::PayoutQueueRotated { asset_kind: 1, index: 0 }.into(), + ); + + // The queue must still satisfy all invariants after the rotation + assert_ok!(Treasury::do_try_state()); + }); +} + +#[test] +fn rotated_spend_keeps_queue_sorted_and_passes_try_state() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Two immediately payable spends and one not yet mature + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(100), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(200), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 2, + Box::new(300), + Some(10) + )); + + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + assert_eq!(PayoutQueue::::get(1u32), vec![(1, 1), (2, 10)]); + + // Move past the head's order expiration and rotate it + System::set_block_number(4); + let info = Treasury::check_status(RuntimeOrigin::signed(1), 0).unwrap(); + assert_eq!(info.pays_fee, Pays::No); + + // The rotated spend is re-inserted with `now` (4) as its order key, which places it + // behind every mature spend but ahead of the not-yet-mature one, keeping the queue + // sorted. Spend 1 is promoted to NextPayout. + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + assert_eq!(PayoutQueue::::get(1u32), vec![(0, 4), (2, 10)]); + + // The sortedness invariant must hold after the rotation + assert_ok!(Treasury::do_try_state()); + }); +} + +#[test] +fn check_status_does_not_rotate_if_not_expired() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create two spends + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(100), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(200), None)); + + // Try check_status before expiration - should fail with NotAttempted + assert_noop!( + Treasury::check_status(RuntimeOrigin::signed(1), 0), + Error::::NotAttempted + ); + + // State should be unchanged + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + }); +} + +#[test] +fn check_status_removes_completed_spend_and_promotes_next() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create two spends + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(100), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(200), None)); + + // Payout first spend + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + let payment_id = get_payment_id(0).unwrap(); + set_status(payment_id, PaymentStatus::Success); + + // check_status should remove completed spend and promote next + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 0)); + + // Spend 1 should now be NextPayout + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + assert_eq!(PayoutQueue::::get(1u32), vec![]); + }); +} + +#[test] +fn void_spend_removes_from_queue_and_promotes_next() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create two spends + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(100), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(200), None)); + + // Void the first spend + assert_ok!(Treasury::void_spend(RuntimeOrigin::root(), 0)); + + // Spend 1 should now be NextPayout + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + assert_eq!(PayoutQueue::::get(1u32), vec![]); + + // Spend 0 should be removed + assert_eq!(Spends::::get(0), None); + }); +} + +#[test] +fn fifo_ordering_enforced_per_asset() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create three spends in order for same asset + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(100), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(200), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(300), None)); + + // Payout should be in FIFO order + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + + // Payout spend 0 + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + let payment_id = get_payment_id(0).unwrap(); + set_status(payment_id, PaymentStatus::Success); + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 0)); + + // Next should be spend 1 + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + + // Payout spend 1 + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 1)); + let payment_id = get_payment_id(1).unwrap(); + set_status(payment_id, PaymentStatus::Success); + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 1)); + + // Next should be spend 2 + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(2)); + + // Verify beneficiaries received in order + assert_eq!(paid(100, 1), 1); + assert_eq!(paid(200, 1), 2); + }); +} + +#[test] +fn queue_full_scenario() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create spends up to MaxQueuedSpends for asset 1 + for i in 0..::MaxQueuedSpends::get().saturating_add(1) { + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(i as u128), + None + )); + } + + // Next spend should fail with QueueFull + assert_noop!( + Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(999u128), None), + Error::::QueueFull + ); + + // But can still create spends for different asset + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(2), + 1, + Box::new(1000u128), + None + )); + }); +} + +#[test] +fn complex_scenario_with_rotation_and_completion() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create three spends + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(100), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(200), None)); + assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(300), None)); + + // First payout succeeds + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + let payment_id = get_payment_id(0).unwrap(); + set_status(payment_id, PaymentStatus::Success); + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 0)); + + // Second payout fails but stays in queue + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 1)); + let payment_id = get_payment_id(1).unwrap(); + set_status(payment_id, PaymentStatus::Failure); + unpay(200, 1, 200); + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 1)); + + // Move past order expiration for spend 1 + System::set_block_number(4); + + // check_status should rotate the queue + let info = Treasury::check_status(RuntimeOrigin::signed(1), 1).unwrap(); + assert_eq!(info.pays_fee, Pays::No); + + // Spend 2 should now be NextPayout + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(2)); + assert_ok!(Treasury::do_try_state()); + + // Payout spend 2 + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 2)); + let payment_id = get_payment_id(2).unwrap(); + set_status(payment_id, PaymentStatus::Success); + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 2)); + + // Spend 1 should be back at head after rotation + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + + // Retry spend 1 + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 1)); + }); +} + +#[test] +fn try_state_payout_queue_invariants() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create some spends + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(100), + Some(5) + )); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 2, + Box::new(200), + Some(3) + )); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(300), + Some(7) + )); + + // Check invariants pass + assert!(Treasury::do_try_state().is_ok()); + + // Verify queue is sorted + let queue = PayoutQueue::::get(1u32); + assert_eq!(queue, vec![(0, 5), (2, 7)]); + }); +} + +#[test] +fn early_spend_cannot_be_paid_before_valid_from() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Create a spend with future valid_from + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(100), + Some(10) + )); + + // Even though it's the NextPayout, it can't be paid before valid_from + assert_noop!(Treasury::payout(RuntimeOrigin::signed(1), 0), Error::::EarlyPayout); + + // Move to valid_from block + System::set_block_number(10); + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + }); +} + +#[test] +fn preemption_earlier_maturing_spend_takes_head() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(10); + + // Spend 0 matures far in the future and, with an empty queue, becomes the head. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(100), + Some(100) + )); + // Spend 1 is approved later but matures now, so its earlier order key (10 < 100) preempts + // the head; the far-future spend is demoted into the queue. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(200), + Some(10) + )); + + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + assert_eq!(PayoutQueue::::get(1u32), vec![(0, 100)]); + + // The preempting spend is already mature, so it is payable immediately. + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 1)); + assert_eq!(paid(200, 1), 1); + }); +} + +#[test] +fn order_key_clamp_prevents_backdated_overtaking() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(5); + + // Spend 0 is already mature; its order key clamps to now (5) and it becomes the head. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(100), + Some(3) + )); + // Spend 1 has an earlier valid_from (1) but is approved later (both still within the payout + // window). Its order key also clamps to 5, so it does NOT overtake the already-mature head + // — it queues behind it rather than back-dating its way to the front. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(200), + Some(1) + )); + + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + assert_eq!(PayoutQueue::::get(1u32), vec![(1, 5)]); + }); +} + +#[test] +fn preemption_among_not_yet_mature_spends() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(5); + + // Spend 0 matures at 100 and becomes the head. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(100), + Some(100) + )); + // Spend 1 matures earlier (50); while both are still in the future it preempts the head. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(200), + Some(50) + )); + + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + assert_eq!(PayoutQueue::::get(1u32), vec![(0, 100)]); + + // Neither is payable yet: the head has not matured and the other is not the head. + assert_noop!(Treasury::payout(RuntimeOrigin::signed(1), 1), Error::::EarlyPayout); + assert_noop!( + Treasury::payout(RuntimeOrigin::signed(1), 0), + Error::::NotNextPayout + ); + }); +} + +#[test] +fn preempt_into_full_queue_fails_with_queue_full() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(10); + let max = ::MaxQueuedSpends::get(); + + // A head with a far-future order key, so a later spend can preempt it. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(0), + Some(2000) + )); + + // Fill the queue to capacity with spends that mature after the head, so they line up behind + // it without preempting. + for i in 0..max { + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new((i + 1) as u128), + Some(3000 + i as u64), + )); + } + + // A mature spend preempts the head, which demotes the old head into the queue. Unlike + // rotation, preemption adds a spend, so with the queue already full there is no room and + // the call must fail rather than exceed the bound. + assert_noop!( + Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(9999), Some(10)), + Error::::QueueFull + ); + }); +} + +#[test] +fn mature_spend_is_paid_before_far_future_head() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(10); + + // A far-future spend is approved first and is initially the head. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(100), + Some(5000) + )); + // A later-approved but already-mature spend. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(200), + Some(10) + )); + + // The mature spend preempts the head and is paid first; the far-future spend waits in the + // queue rather than blocking it. + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 1)); + assert_eq!(paid(200, 1), 1); + assert_noop!( + Treasury::payout(RuntimeOrigin::signed(1), 0), + Error::::NotNextPayout + ); + }); +} + +// Rotation is size-neutral (demote the head, promote one entry), so it must succeed even when the +// queue is full. Otherwise the expired head can never be rotated and stays stuck as `NextPayout`. +#[test] +fn rotate_payout_queue_does_not_deadlock_when_queue_is_full() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let max = ::MaxQueuedSpends::get(); + // One head + `MaxQueuedSpends` queued fills the queue exactly. All created at block 1 with + // `valid_from = None`, so every order key is `1` and the head's order expires at + // `1 + OrderExpirationPeriod`. Root origin avoids the per-origin spend budget. + for i in 0..=max { + assert_ok!(Treasury::spend( + RuntimeOrigin::root(), + Box::new(1), + 1, + Box::new(i as u128), + None + )); + } + assert_eq!(PayoutQueue::::get(1u32).len() as u32, max); + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + + // Move past the head's order expiration so `check_status` rotates it. + System::set_block_number(4); + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 0)); + + // Head rotated to the back, spend 1 promoted; queue size unchanged. + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(1)); + let queue = PayoutQueue::::get(1u32); + assert_eq!(queue.len() as u32, max); + assert_eq!(queue.last(), Some(&(0, 4))); + assert_ok!(Treasury::do_try_state()); + }); +} + +// When a new spend preempts the head, the demoted head must be placed ahead of queue entries that +// share its order key, since it was approved before them. +#[test] +fn head_preemption_preserves_fifo_among_equal_order_keys() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + // Spend 0 becomes the head with order_key = max(1, 20) = 20. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(100), + Some(20) + )); + // Spend 1 has the same order_key 20 (not strictly earlier), so it joins the queue. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(200), + Some(20) + )); + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(0)); + assert_eq!(PayoutQueue::::get(1u32), vec![(1, 20)]); + + // Spend 2 matures strictly earlier (order_key = max(1, 15) = 15 < 20) and preempts the + // head. The demoted head (index 0) was approved before spend 1 and shares order_key 20, so + // FIFO requires it ahead of spend 1. + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(1), + 1, + Box::new(300), + Some(15) + )); + + assert_eq!(NextPayout::::get(1u32).map(|(idx, _, _)| idx), Some(2)); + assert_eq!(PayoutQueue::::get(1u32), vec![(0, 20), (1, 20)]); + }); +} diff --git a/substrate/frame/treasury/src/weights.rs b/substrate/frame/treasury/src/weights.rs index b2c9da1ffddb..9f45e73a3c7d 100644 --- a/substrate/frame/treasury/src/weights.rs +++ b/substrate/frame/treasury/src/weights.rs @@ -35,9 +35,9 @@ //! Autogenerated weights for `pallet_treasury` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 -//! DATE: 2025-02-21, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-06-29, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `4563561839a5`, CPU: `Intel(R) Xeon(R) CPU @ 2.60GHz` +//! HOSTNAME: `1807571685ea`, CPU: `Intel(R) Xeon(R) CPU @ 2.60GHz` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` // Executed Command: @@ -58,8 +58,7 @@ // --no-storage-info // --no-min-squares // --no-median-slopes -// --genesis-builder-policy=none -// --exclude-pallets=pallet_xcm,pallet_xcm_benchmarks::fungible,pallet_xcm_benchmarks::generic,pallet_nomination_pools,pallet_remark,pallet_transaction_storage,pallet_election_provider_multi_block,pallet_election_provider_multi_block::signed,pallet_election_provider_multi_block::unsigned,pallet_election_provider_multi_block::verifier +// --exclude-pallets=pallet_xcm,pallet_xcm_benchmarks::fungible,pallet_xcm_benchmarks::generic,pallet_nomination_pools,pallet_remark,pallet_transaction_storage #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -78,6 +77,7 @@ pub trait WeightInfo { fn spend() -> Weight; fn payout() -> Weight; fn check_status() -> Weight; + fn check_status_rotation() -> Weight; fn void_spend() -> Weight; } @@ -92,10 +92,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Treasury::Proposals` (`max_values`: None, `max_size`: Some(108), added: 2583, mode: `MaxEncodedLen`) fn spend_local() -> Weight { // Proof Size summary in bytes: - // Measured: `0` + // Measured: `76` // Estimated: `1887` - // Minimum execution time: 9_084_000 picoseconds. - Weight::from_parts(9_260_000, 1887) + // Minimum execution time: 11_815_000 picoseconds. + Weight::from_parts(12_913_000, 1887) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -103,10 +103,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Treasury::Approvals` (`max_values`: Some(1), `max_size`: Some(402), added: 897, mode: `MaxEncodedLen`) fn remove_approval() -> Weight { // Proof Size summary in bytes: - // Measured: `69` + // Measured: `161` // Estimated: `1887` - // Minimum execution time: 5_149_000 picoseconds. - Weight::from_parts(5_358_000, 1887) + // Minimum execution time: 6_556_000 picoseconds. + Weight::from_parts(7_287_000, 1887) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -117,12 +117,12 @@ impl WeightInfo for SubstrateWeight { /// The range of component `p` is `[0, 99]`. fn on_initialize_proposals(p: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `78` + // Measured: `170` // Estimated: `1501` - // Minimum execution time: 8_211_000 picoseconds. - Weight::from_parts(11_324_784, 1501) - // Standard Error: 806 - .saturating_add(Weight::from_parts(45_246, 0).saturating_mul(p.into())) + // Minimum execution time: 11_141_000 picoseconds. + Weight::from_parts(13_838_963, 1501) + // Standard Error: 251 + .saturating_add(Weight::from_parts(32_789, 0).saturating_mul(p.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -130,19 +130,25 @@ impl WeightInfo for SubstrateWeight { /// Proof: `AssetRate::ConversionRateToNative` (`max_values`: None, `max_size`: Some(37), added: 2512, mode: `MaxEncodedLen`) /// Storage: `Treasury::SpendCount` (r:1 w:1) /// Proof: `Treasury::SpendCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(815), added: 3290, mode: `MaxEncodedLen`) /// Storage: `Treasury::Spends` (r:0 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) fn spend() -> Weight { // Proof Size summary in bytes: - // Measured: `42` - // Estimated: `3502` - // Minimum execution time: 11_348_000 picoseconds. - Weight::from_parts(11_874_000, 3502) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().writes(2_u64)) + // Measured: `1020` + // Estimated: `4280` + // Minimum execution time: 29_422_000 picoseconds. + Weight::from_parts(31_564_000, 4280) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) } /// Storage: `Treasury::Spends` (r:1 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:0) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) /// Storage: `Assets::Asset` (r:1 w:1) /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) /// Storage: `Assets::Account` (r:2 w:2) @@ -151,34 +157,57 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn payout() -> Weight { // Proof Size summary in bytes: - // Measured: `473` + // Measured: `824` // Estimated: `6208` - // Minimum execution time: 55_665_000 picoseconds. - Weight::from_parts(57_099_000, 6208) - .saturating_add(T::DbWeight::get().reads(5_u64)) + // Minimum execution time: 65_968_000 picoseconds. + Weight::from_parts(71_212_000, 6208) + .saturating_add(T::DbWeight::get().reads(6_u64)) .saturating_add(T::DbWeight::get().writes(5_u64)) } /// Storage: `Treasury::Spends` (r:1 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(815), added: 3290, mode: `MaxEncodedLen`) fn check_status() -> Weight { // Proof Size summary in bytes: - // Measured: `143` - // Estimated: `3539` - // Minimum execution time: 12_058_000 picoseconds. - Weight::from_parts(12_297_000, 3539) - .saturating_add(T::DbWeight::get().reads(1_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) + // Measured: `1086` + // Estimated: `4280` + // Minimum execution time: 28_581_000 picoseconds. + Weight::from_parts(30_728_000, 4280) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: `Treasury::Spends` (r:1 w:0) + /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(815), added: 3290, mode: `MaxEncodedLen`) + fn check_status_rotation() -> Weight { + // Proof Size summary in bytes: + // Measured: `1078` + // Estimated: `4280` + // Minimum execution time: 26_579_000 picoseconds. + Weight::from_parts(28_594_000, 4280) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Treasury::Spends` (r:1 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(815), added: 3290, mode: `MaxEncodedLen`) fn void_spend() -> Weight { // Proof Size summary in bytes: - // Measured: `144` - // Estimated: `3539` - // Minimum execution time: 10_730_000 picoseconds. - Weight::from_parts(10_908_000, 3539) - .saturating_add(T::DbWeight::get().reads(1_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) + // Measured: `1086` + // Estimated: `4280` + // Minimum execution time: 25_244_000 picoseconds. + Weight::from_parts(27_207_000, 4280) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) } } @@ -192,10 +221,10 @@ impl WeightInfo for () { /// Proof: `Treasury::Proposals` (`max_values`: None, `max_size`: Some(108), added: 2583, mode: `MaxEncodedLen`) fn spend_local() -> Weight { // Proof Size summary in bytes: - // Measured: `0` + // Measured: `76` // Estimated: `1887` - // Minimum execution time: 9_084_000 picoseconds. - Weight::from_parts(9_260_000, 1887) + // Minimum execution time: 11_815_000 picoseconds. + Weight::from_parts(12_913_000, 1887) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } @@ -203,10 +232,10 @@ impl WeightInfo for () { /// Proof: `Treasury::Approvals` (`max_values`: Some(1), `max_size`: Some(402), added: 897, mode: `MaxEncodedLen`) fn remove_approval() -> Weight { // Proof Size summary in bytes: - // Measured: `69` + // Measured: `161` // Estimated: `1887` - // Minimum execution time: 5_149_000 picoseconds. - Weight::from_parts(5_358_000, 1887) + // Minimum execution time: 6_556_000 picoseconds. + Weight::from_parts(7_287_000, 1887) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -217,12 +246,12 @@ impl WeightInfo for () { /// The range of component `p` is `[0, 99]`. fn on_initialize_proposals(p: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `78` + // Measured: `170` // Estimated: `1501` - // Minimum execution time: 8_211_000 picoseconds. - Weight::from_parts(11_324_784, 1501) - // Standard Error: 806 - .saturating_add(Weight::from_parts(45_246, 0).saturating_mul(p.into())) + // Minimum execution time: 11_141_000 picoseconds. + Weight::from_parts(13_838_963, 1501) + // Standard Error: 251 + .saturating_add(Weight::from_parts(32_789, 0).saturating_mul(p.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -230,19 +259,25 @@ impl WeightInfo for () { /// Proof: `AssetRate::ConversionRateToNative` (`max_values`: None, `max_size`: Some(37), added: 2512, mode: `MaxEncodedLen`) /// Storage: `Treasury::SpendCount` (r:1 w:1) /// Proof: `Treasury::SpendCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(815), added: 3290, mode: `MaxEncodedLen`) /// Storage: `Treasury::Spends` (r:0 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) fn spend() -> Weight { // Proof Size summary in bytes: - // Measured: `42` - // Estimated: `3502` - // Minimum execution time: 11_348_000 picoseconds. - Weight::from_parts(11_874_000, 3502) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().writes(2_u64)) + // Measured: `1020` + // Estimated: `4280` + // Minimum execution time: 29_422_000 picoseconds. + Weight::from_parts(31_564_000, 4280) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(4_u64)) } /// Storage: `Treasury::Spends` (r:1 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:0) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) /// Storage: `Assets::Asset` (r:1 w:1) /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) /// Storage: `Assets::Account` (r:2 w:2) @@ -251,33 +286,56 @@ impl WeightInfo for () { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn payout() -> Weight { // Proof Size summary in bytes: - // Measured: `473` + // Measured: `824` // Estimated: `6208` - // Minimum execution time: 55_665_000 picoseconds. - Weight::from_parts(57_099_000, 6208) - .saturating_add(RocksDbWeight::get().reads(5_u64)) + // Minimum execution time: 65_968_000 picoseconds. + Weight::from_parts(71_212_000, 6208) + .saturating_add(RocksDbWeight::get().reads(6_u64)) .saturating_add(RocksDbWeight::get().writes(5_u64)) } /// Storage: `Treasury::Spends` (r:1 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(815), added: 3290, mode: `MaxEncodedLen`) fn check_status() -> Weight { // Proof Size summary in bytes: - // Measured: `143` - // Estimated: `3539` - // Minimum execution time: 12_058_000 picoseconds. - Weight::from_parts(12_297_000, 3539) - .saturating_add(RocksDbWeight::get().reads(1_u64)) - .saturating_add(RocksDbWeight::get().writes(1_u64)) + // Measured: `1086` + // Estimated: `4280` + // Minimum execution time: 28_581_000 picoseconds. + Weight::from_parts(30_728_000, 4280) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: `Treasury::Spends` (r:1 w:0) + /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(815), added: 3290, mode: `MaxEncodedLen`) + fn check_status_rotation() -> Weight { + // Proof Size summary in bytes: + // Measured: `1078` + // Estimated: `4280` + // Minimum execution time: 26_579_000 picoseconds. + Weight::from_parts(28_594_000, 4280) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Treasury::Spends` (r:1 w:1) /// Proof: `Treasury::Spends` (`max_values`: None, `max_size`: Some(74), added: 2549, mode: `MaxEncodedLen`) + /// Storage: `Treasury::NextPayout` (r:1 w:1) + /// Proof: `Treasury::NextPayout` (`max_values`: None, `max_size`: Some(25), added: 2500, mode: `MaxEncodedLen`) + /// Storage: `Treasury::PayoutQueue` (r:1 w:1) + /// Proof: `Treasury::PayoutQueue` (`max_values`: None, `max_size`: Some(815), added: 3290, mode: `MaxEncodedLen`) fn void_spend() -> Weight { // Proof Size summary in bytes: - // Measured: `144` - // Estimated: `3539` - // Minimum execution time: 10_730_000 picoseconds. - Weight::from_parts(10_908_000, 3539) - .saturating_add(RocksDbWeight::get().reads(1_u64)) - .saturating_add(RocksDbWeight::get().writes(1_u64)) + // Measured: `1086` + // Estimated: `4280` + // Minimum execution time: 25_244_000 picoseconds. + Weight::from_parts(27_207_000, 4280) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) } }