From 1662dc691319bf8ba942e7e0810d59c51d6dfb1b Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Thu, 29 Jan 2026 10:39:28 +0800 Subject: [PATCH 01/15] feat: Multisig + HS integrated --- Cargo.lock | 1 + pallets/multisig/Cargo.toml | 4 ++ pallets/multisig/src/lib.rs | 22 ++++++ pallets/multisig/src/mock.rs | 1 + pallets/multisig/src/tests.rs | 91 +++++++++++++++++++++++++ pallets/reversible-transfers/src/lib.rs | 41 +++++++++++ runtime/src/configs/mod.rs | 43 +++++++++++- runtime/src/transaction_extensions.rs | 29 +++----- 8 files changed, 212 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f186480..a8c52217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7179,6 +7179,7 @@ dependencies = [ "frame-system", "log", "pallet-balances 40.0.1", + "pallet-reversible-transfers", "pallet-timestamp", "parity-scale-codec", "scale-info", diff --git a/pallets/multisig/Cargo.toml b/pallets/multisig/Cargo.toml index 0d768485..34f2389a 100644 --- a/pallets/multisig/Cargo.toml +++ b/pallets/multisig/Cargo.toml @@ -18,6 +18,7 @@ frame-support.workspace = true frame-system.workspace = true log.workspace = true pallet-balances.workspace = true +pallet-reversible-transfers = { path = "../reversible-transfers", default-features = false } scale-info = { features = ["derive"], workspace = true } sp-arithmetic.workspace = true sp-core.workspace = true @@ -38,6 +39,7 @@ runtime-benchmarks = [ "frame-support/runtime-benchmarks", "frame-system/runtime-benchmarks", "pallet-balances/runtime-benchmarks", + "pallet-reversible-transfers/runtime-benchmarks", "sp-runtime/runtime-benchmarks", ] std = [ @@ -47,6 +49,7 @@ std = [ "frame-system/std", "log/std", "pallet-balances/std", + "pallet-reversible-transfers/std", "pallet-timestamp/std", "scale-info/std", "sp-arithmetic/std", @@ -57,5 +60,6 @@ std = [ try-runtime = [ "frame-support/try-runtime", "frame-system/try-runtime", + "pallet-reversible-transfers/try-runtime", "sp-runtime/try-runtime", ] diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 3befe0fc..a4ede381 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -205,8 +205,17 @@ pub mod pallet { /// Weight information for extrinsics type WeightInfo: WeightInfo; + + /// Interface to check if an account is in high-security mode + type HighSecurity: pallet_reversible_transfers::HighSecurityInspector< + Self::AccountId, + ::RuntimeCall, + >; } + /// Re-export HighSecurityInspector trait from reversible-transfers for convenience + pub use pallet_reversible_transfers::HighSecurityInspector; + /// Type alias for bounded signers vector pub type BoundedSignersOf = BoundedVec<::AccountId, ::MaxSigners>; @@ -375,6 +384,8 @@ pub mod pallet { ProposalsExist, /// Multisig account must have zero balance before dissolution MultisigAccountNotZero, + /// Call is not allowed for high-security multisig + CallNotAllowedForHighSecurityMultisig, } #[pallet::call] @@ -508,6 +519,16 @@ pub mod pallet { Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; ensure!(multisig_data.signers.contains(&proposer), Error::::NotASigner); + // High-security check: if multisig is high-security, only whitelisted calls allowed + if T::HighSecurity::is_high_security(&multisig_address) { + let decoded_call = ::RuntimeCall::decode(&mut &call[..]) + .map_err(|_| Error::::InvalidCall)?; + ensure!( + T::HighSecurity::is_whitelisted(&decoded_call), + Error::::CallNotAllowedForHighSecurityMultisig + ); + } + // Auto-cleanup expired proposals before creating new one // This is the primary cleanup mechanism for active multisigs Self::auto_cleanup_expired_proposals(&multisig_address, &proposer); @@ -515,6 +536,7 @@ pub mod pallet { // Reload multisig data after potential cleanup let multisig_data = Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; + let current_block = frame_system::Pallet::::block_number(); // Get signers count (used for multiple checks below) diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index 38f241d6..4ef3b258 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -114,6 +114,7 @@ impl pallet_multisig::Config for Test { type MaxExpiryDuration = MaxExpiryDurationParam; type PalletId = MultisigPalletId; type WeightInfo = (); + type HighSecurity = crate::tests::MockHighSecurity; } // Helper to create AccountId32 from u64 diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 277672ac..792432dc 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -3,8 +3,32 @@ use crate::{mock::*, Error, Event, GlobalNonce, Multisigs, ProposalStatus, Proposals}; use codec::Encode; use frame_support::{assert_noop, assert_ok, traits::fungible::Mutate}; +use pallet_reversible_transfers::HighSecurityInspector; use sp_core::crypto::AccountId32; +/// Mock implementation for HighSecurityInspector +pub struct MockHighSecurity; +impl HighSecurityInspector for MockHighSecurity { + fn is_high_security(who: &AccountId32) -> bool { + // For testing, account 100 is high security + who == &account_id(100) + } + fn is_whitelisted(call: &RuntimeCall) -> bool { + // For testing, only remarks with "safe" are whitelisted + match call { + RuntimeCall::System(frame_system::Call::remark { remark }) => remark == b"safe", + _ => false, + } + } + fn guardian(who: &AccountId32) -> Option { + if who == &account_id(100) { + Some(account_id(200)) // Guardian is account 200 + } else { + None + } + } +} + /// Helper function to get Alice's account ID fn alice() -> AccountId32 { account_id(1) @@ -1323,3 +1347,70 @@ fn auto_cleanup_on_approve_and_cancel() { assert_eq!(multisig_data.active_proposals, 0); }); } + +// ==================== HIGH SECURITY TESTS ==================== + +#[test] +fn high_security_propose_fails_for_non_whitelisted_call() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + // Create a multisig with account_id(100) as one of signers + // We'll manually insert it as high-security multisig + let multisig_address = account_id(100); + let signers = vec![alice(), bob()]; + + Multisigs::::insert( + &multisig_address, + crate::MultisigData { + signers: signers.try_into().unwrap(), + threshold: 2, + nonce: 0, + proposal_nonce: 0, + creator: alice(), + deposit: 500, + last_activity: 1, + active_proposals: 0, + proposals_per_signer: Default::default(), + }, + ); + + // Try to propose a non-whitelisted call (remark without "safe") + let call = make_call(b"unsafe".to_vec()); + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(alice()), multisig_address.clone(), call, 1000), + Error::::CallNotAllowedForHighSecurityMultisig + ); + + // Try to propose a whitelisted call (remark with "safe") - should work + let call = make_call(b"safe".to_vec()); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(alice()), + multisig_address.clone(), + call, + 1000 + )); + }); +} + +#[test] +fn normal_multisig_allows_any_call() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + // Create a normal multisig (not high-security) + let signers = vec![alice(), bob(), charlie()]; + let threshold = 2; + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(alice()), + signers.clone(), + threshold + )); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Any call should work for normal multisig + let call = make_call(b"anything".to_vec()); + assert_ok!(Multisig::propose(RuntimeOrigin::signed(alice()), multisig_address, call, 1000)); + }); +} diff --git a/pallets/reversible-transfers/src/lib.rs b/pallets/reversible-transfers/src/lib.rs index de235288..ec8d4463 100644 --- a/pallets/reversible-transfers/src/lib.rs +++ b/pallets/reversible-transfers/src/lib.rs @@ -37,6 +37,47 @@ use qp_scheduler::{BlockNumberOrTimestamp, DispatchTime, ScheduleNamed}; use sp_arithmetic::Permill; use sp_runtime::traits::StaticLookup; +/// Trait for checking high-security status and whitelisting calls. +/// This can be used by other pallets (like multisig) or transaction extensions. +pub trait HighSecurityInspector { + /// Check if account is high-security + fn is_high_security(who: &AccountId) -> bool; + /// Check if call is whitelisted for high-security accounts + /// Note: This must be implemented at runtime level since it needs RuntimeCall + fn is_whitelisted(call: &RuntimeCall) -> bool; + /// Get guardian for high-security account + fn guardian(who: &AccountId) -> Option; +} + +/// Default implementation for HighSecurityInspector (no high-security) +/// This allows pallets to have optional high-security support by using `type HighSecurity = ();` +impl HighSecurityInspector for () { + fn is_high_security(_who: &AccountId) -> bool { + false + } + fn is_whitelisted(_call: &RuntimeCall) -> bool { + true // Allow everything if no high-security + } + fn guardian(_who: &AccountId) -> Option { + None + } +} + +// Partial implementation for Pallet - runtime will complete it +impl Pallet { + /// Check if account is registered as high-security + /// This is used by runtime's HighSecurityInspector implementation + pub fn is_high_security_account(who: &T::AccountId) -> bool { + HighSecurityAccounts::::contains_key(who) + } + + /// Get guardian for high-security account + /// This is used by runtime's HighSecurityInspector implementation + pub fn get_guardian(who: &T::AccountId) -> Option { + HighSecurityAccounts::::get(who).map(|data| data.interceptor) + } +} + /// Type alias for this config's `BlockNumberOrTimestamp`. pub type BlockNumberOrTimestampOf = BlockNumberOrTimestamp, ::Moment>; diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index a439621c..fd6da282 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -577,7 +577,47 @@ parameter_types! { pub const MaxExpiryDuration: BlockNumber = 100_800; // ~2 weeks at 12s blocks (14 days * 24h * 60m * 60s / 12s) } -/// Whitelist for calls that can be proposed in multisigs +/// High-Security configuration wrapper for Runtime +/// +/// This type alias delegates to `ReversibleTransfers` pallet for high-security checks +/// and adds RuntimeCall-specific whitelist validation. +/// +/// Used by: +/// - Multisig pallet: validates calls in `propose()` extrinsic +/// - Transaction extensions: validates calls for high-security EOAs +/// +/// Whitelist includes only delayed, reversible operations: +/// - `schedule_transfer`: Schedule delayed native token transfer +/// - `schedule_asset_transfer`: Schedule delayed asset transfer +/// - `cancel`: Cancel pending delayed transfer +pub struct HighSecurityConfig; + +impl pallet_reversible_transfers::HighSecurityInspector + for HighSecurityConfig +{ + fn is_high_security(who: &AccountId) -> bool { + // Delegate to reversible-transfers pallet + pallet_reversible_transfers::Pallet::::is_high_security_account(who) + } + + fn is_whitelisted(call: &RuntimeCall) -> bool { + // Runtime-level whitelist: only reversible-transfers operations allowed + matches!( + call, + RuntimeCall::ReversibleTransfers( + pallet_reversible_transfers::Call::schedule_transfer { .. } + ) | RuntimeCall::ReversibleTransfers( + pallet_reversible_transfers::Call::schedule_asset_transfer { .. } + ) | RuntimeCall::ReversibleTransfers(pallet_reversible_transfers::Call::cancel { .. }) + ) + } + + fn guardian(who: &AccountId) -> Option { + // Delegate to reversible-transfers pallet + pallet_reversible_transfers::Pallet::::get_guardian(who) + } +} + impl pallet_multisig::Config for Runtime { type RuntimeCall = RuntimeCall; type Currency = Balances; @@ -592,6 +632,7 @@ impl pallet_multisig::Config for Runtime { type MaxExpiryDuration = MaxExpiryDuration; type PalletId = MultisigPalletId; type WeightInfo = pallet_multisig::weights::SubstrateWeight; + type HighSecurity = HighSecurityConfig; } impl TryFrom for pallet_balances::Call { diff --git a/runtime/src/transaction_extensions.rs b/runtime/src/transaction_extensions.rs index 5afed1e6..0bbfc2b3 100644 --- a/runtime/src/transaction_extensions.rs +++ b/runtime/src/transaction_extensions.rs @@ -5,6 +5,7 @@ use core::marker::PhantomData; use frame_support::pallet_prelude::{InvalidTransaction, ValidTransaction}; use frame_system::ensure_signed; +use pallet_reversible_transfers::HighSecurityInspector; use scale_info::TypeInfo; use sp_core::Get; use sp_runtime::{traits::TransactionExtension, Weight}; @@ -65,25 +66,15 @@ impl ) })?; - if ReversibleTransfers::is_high_security(&who).is_some() { - // High-security accounts can only call schedule_transfer and cancel - match call { - RuntimeCall::ReversibleTransfers( - pallet_reversible_transfers::Call::schedule_transfer { .. }, - ) | - RuntimeCall::ReversibleTransfers( - pallet_reversible_transfers::Call::schedule_asset_transfer { .. }, - ) | - RuntimeCall::ReversibleTransfers(pallet_reversible_transfers::Call::cancel { - .. - }) => { - return Ok((ValidTransaction::default(), (), origin)); - }, - _ => { - return Err(frame_support::pallet_prelude::TransactionValidityError::Invalid( - InvalidTransaction::Custom(1), - )); - }, + // Check if account is high-security using the same inspector as multisig + if crate::configs::HighSecurityConfig::is_high_security(&who) { + // Use the same whitelist check as multisig + if crate::configs::HighSecurityConfig::is_whitelisted(call) { + return Ok((ValidTransaction::default(), (), origin)); + } else { + return Err(frame_support::pallet_prelude::TransactionValidityError::Invalid( + InvalidTransaction::Custom(1), + )); } } From 4eba306f3f5c5b46a6797330bd80e19004f4ee0a Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Thu, 29 Jan 2026 12:17:41 +0800 Subject: [PATCH 02/15] feat: Weights update --- pallets/multisig/README.md | 124 +++++++++++++++++ pallets/multisig/src/benchmarking.rs | 96 +++++++++++++ pallets/multisig/src/lib.rs | 27 +++- pallets/multisig/src/weights.rs | 178 ++++++++++++++++--------- pallets/reversible-transfers/README.md | 128 +++++++++++++++++- 5 files changed, 484 insertions(+), 69 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index a7853087..bd49e4b7 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -61,6 +61,7 @@ Creates a new proposal for multisig execution. **Validation:** - Caller must be a signer +- **High-Security Check:** If multisig is high-security, only whitelisted calls are allowed (see High-Security Integration section) - Call size must be ≤ MaxCallSize - Multisig cannot have MaxTotalProposalsInStorage or more total proposals in storage - Caller cannot exceed their per-signer proposal limit (`MaxTotalProposalsInStorage / signers_count`) @@ -507,6 +508,129 @@ impl pallet_multisig::Config for Runtime { - **Enterprise use:** Higher MaxSigners, longer MaxExpiryDuration - **Public use:** Moderate limits, shorter expiry for faster turnover +## High-Security Integration + +The multisig pallet integrates with **pallet-reversible-transfers** to support high-security multisigs with call whitelisting and delayed execution. + +### Overview + +**Standard Multisig:** +- Proposes any `RuntimeCall` +- Executes immediately on threshold +- No restrictions + +**High-Security Multisig:** +- **Whitelist enforced:** Only allowed calls can be proposed +- **Delayed execution:** Via `ReversibleTransfers::schedule_transfer()` +- **Guardian oversight:** Guardian can cancel during delay period +- **Use case:** Corporate treasury, regulated operations, high-value custody + +### How It Works + +1. **Setup:** Multisig account calls `ReversibleTransfers::set_high_security(delay, guardian)` +2. **Propose:** Only whitelisted calls allowed: + - ✅ `ReversibleTransfers::schedule_transfer` + - ✅ `ReversibleTransfers::schedule_asset_transfer` + - ✅ `ReversibleTransfers::cancel` + - ❌ All other calls → `CallNotAllowedForHighSecurityMultisig` error +3. **Approve:** Standard multisig approval process +4. **Execute:** Threshold reached → transfer scheduled with delay +5. **Guardian:** Can cancel via `ReversibleTransfers::cancel(tx_id)` during delay + +### Code Example + +```rust +// 1. Create standard 3-of-5 multisig +let multisig_addr = Multisig::create_multisig( + Origin::signed(alice), + vec![alice, bob, charlie, dave, eve], + 3 +); + +// 2. Enable high-security (via multisig proposal + approvals) +// Propose and get 3 approvals for: +ReversibleTransfers::set_high_security( + Origin::signed(multisig_addr), + delay: 100_800, // 2 weeks @ 12s blocks + guardian: guardian_account +); + +// 3. Now only whitelisted calls work +// ✅ ALLOWED: Schedule delayed transfer +Multisig::propose( + Origin::signed(alice), + multisig_addr, + RuntimeCall::ReversibleTransfers( + Call::schedule_transfer { dest: recipient, amount: 1000 } + ).encode(), + expiry +); +// → Whitelist check passes +// → Collect approvals +// → Transfer scheduled with 2-week delay +// → Guardian can cancel if suspicious + +// ❌ REJECTED: Direct transfer +Multisig::propose( + Origin::signed(alice), + multisig_addr, + RuntimeCall::Balances( + Call::transfer { dest: recipient, amount: 1000 } + ).encode(), + expiry +); +// → ERROR: CallNotAllowedForHighSecurityMultisig +// → Proposal fails immediately +``` + +### Performance Impact + +High-security multisigs have slightly higher costs due to call validation: + +- **+1 DB read:** Check `ReversibleTransfers::HighSecurityAccounts` +- **+Decode overhead:** ~50k units per call byte +- **+Whitelist check:** ~10k units for pattern matching +- **Total overhead:** ~25M base + 50k/byte for typical 1KB call + +**Dynamic weight refund:** +Normal multisigs automatically get refunded for unused high-security overhead (typically 92-94% refund). + +See `MULTISIG_REQ.md` for detailed cost breakdown and benchmarking instructions. + +### Configuration + +```rust +impl pallet_multisig::Config for Runtime { + type HighSecurity = runtime::HighSecurityConfig; + // ... other config +} + +// Runtime implements HighSecurityInspector trait +pub struct HighSecurityConfig; +impl HighSecurityInspector for HighSecurityConfig { + fn is_high_security(who: &AccountId) -> bool { + ReversibleTransfers::is_high_security_account(who) + } + + fn is_whitelisted(call: &RuntimeCall) -> bool { + matches!(call, + RuntimeCall::ReversibleTransfers(Call::schedule_transfer { .. }) | + RuntimeCall::ReversibleTransfers(Call::schedule_asset_transfer { .. }) | + RuntimeCall::ReversibleTransfers(Call::cancel { .. }) + ) + } + + fn guardian(who: &AccountId) -> Option { + ReversibleTransfers::get_guardian(who) + } +} +``` + +### Documentation + +- See `MULTISIG_REQ.md` for complete high-security integration requirements +- See `pallet-reversible-transfers` docs for guardian management and delay configuration + ## License MIT-0 diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 03db467f..b19af052 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -130,6 +130,102 @@ mod benchmarks { Ok(()) } + #[benchmark] + fn propose_high_security( + c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, + e: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, // expired proposals to cleanup + ) -> Result<(), BenchmarkError> { + // Benchmarks propose() for high-security multisigs (includes decode + whitelist check) + // This is more expensive than normal propose due to: + // 1. is_high_security() check (1 DB read from ReversibleTransfers::HighSecurityAccounts) + // 2. RuntimeCall decode (O(c) overhead - scales with call size) + // 3. is_whitelisted() pattern matching + // + // NOTE: This benchmark measures the OVERHEAD of high-security checks, + // not the functionality. The actual HighSecurity implementation is runtime-specific. + // Mock implementation in tests would need to recognize this multisig as HS, + // but for weight measurement, we're benchmarking the worst-case: full decode path. + // + // In production, the runtime's HighSecurityConfig will check: + // - pallet_reversible_transfers::HighSecurityAccounts storage + // - Pattern match against RuntimeCall variants + + // Setup: Create a high-security multisig + let caller: T::AccountId = whitelisted_caller(); + fund_account::(&caller, BalanceOf2::::from(100000u128)); + + let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); + let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + fund_account::(&signer1, BalanceOf2::::from(100000u128)); + fund_account::(&signer2, BalanceOf2::::from(100000u128)); + + let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let threshold = 2u32; + signers.sort(); + + // Create multisig directly in storage + let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); + let multisig_data = MultisigDataOf:: { + signers: bounded_signers, + threshold, + nonce: 0, + proposal_nonce: e, + creator: caller.clone(), + deposit: T::MultisigDeposit::get(), + last_activity: frame_system::Pallet::::block_number(), + active_proposals: e, + proposals_per_signer: BoundedBTreeMap::new(), + }; + Multisigs::::insert(&multisig_address, multisig_data); + + // IMPORTANT: Set this multisig as high-security + // Note: This is a mock setup - in production, HighSecurity implementation + // would check reversible-transfers storage. For benchmarking, we assume + // the HighSecurity type implementation recognizes this multisig as HS. + // The benchmark measures the worst-case: decode + whitelist check. + + // Insert e expired proposals (worst case for auto-cleanup) + let expired_block = 10u32.into(); + for i in 0..e { + let system_call = frame_system::Call::::remark { remark: vec![i as u8; 10] }; + let call = ::RuntimeCall::from(system_call); + let encoded_call = call.encode(); + let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); + let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); + + let proposal_data = ProposalDataOf:: { + proposer: caller.clone(), + call: bounded_call, + expiry: expired_block, + approvals: bounded_approvals, + deposit: 10u32.into(), + status: ProposalStatus::Active, + }; + Proposals::::insert(&multisig_address, i, proposal_data); + } + + // Move past expiry so proposals are expired + frame_system::Pallet::::set_block_number(100u32.into()); + + // Create a whitelisted call for high-security (system::remark as placeholder) + // In production this would be ReversibleTransfers::schedule_transfer + // But for benchmarking we measure the decode overhead with system::remark of size c + let system_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; + let call = ::RuntimeCall::from(system_call); + let encoded_call = call.encode(); + let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); + + #[extrinsic_call] + propose(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); + + // Verify new proposal was created and expired ones were cleaned + let multisig = Multisigs::::get(&multisig_address).unwrap(); + assert_eq!(multisig.active_proposals, 1); + + Ok(()) + } + #[benchmark] fn approve( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index a4ede381..99fbd441 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -501,17 +501,21 @@ pub mod pallet { /// cleanup mechanism. /// /// **For threshold=1:** If the multisig threshold is 1, the proposal executes immediately. + /// + /// **Weight:** Charged based on whether multisig is high-security or not. + /// High-security multisigs incur additional cost for decode + whitelist check. #[pallet::call_index(1)] - #[pallet::weight(::WeightInfo::propose( + #[pallet::weight(::WeightInfo::propose_high_security( call.len() as u32, T::MaxTotalProposalsInStorage::get() ))] + #[allow(clippy::useless_conversion)] pub fn propose( origin: OriginFor, multisig_address: T::AccountId, call: Vec, expiry: BlockNumberFor, - ) -> DispatchResult { + ) -> DispatchResultWithPostInfo { let proposer = ensure_signed(origin)?; // Check if proposer is a signer @@ -520,7 +524,8 @@ pub mod pallet { ensure!(multisig_data.signers.contains(&proposer), Error::::NotASigner); // High-security check: if multisig is high-security, only whitelisted calls allowed - if T::HighSecurity::is_high_security(&multisig_address) { + let is_high_security = T::HighSecurity::is_high_security(&multisig_address); + if is_high_security { let decoded_call = ::RuntimeCall::decode(&mut &call[..]) .map_err(|_| Error::::InvalidCall)?; ensure!( @@ -531,7 +536,7 @@ pub mod pallet { // Auto-cleanup expired proposals before creating new one // This is the primary cleanup mechanism for active multisigs - Self::auto_cleanup_expired_proposals(&multisig_address, &proposer); + let iterated_count = Self::auto_cleanup_expired_proposals(&multisig_address, &proposer); // Reload multisig data after potential cleanup let multisig_data = @@ -605,6 +610,9 @@ pub mod pallet { } }); + // Store call length before moving it (needed for weight calculation) + let call_size = call.len() as u32; + // Convert to bounded vec let bounded_call: BoundedCallOf = call.try_into().map_err(|_| Error::::CallTooLarge)?; @@ -667,7 +675,16 @@ pub mod pallet { Self::do_execute(multisig_address, proposal_id, proposal)?; } - Ok(()) + // Calculate actual weight and refund if not high-security + let actual_weight = if is_high_security { + // Used high-security path (decode + whitelist check) + ::WeightInfo::propose_high_security(call_size, iterated_count) + } else { + // Used normal path (no decode overhead) + ::WeightInfo::propose(call_size, iterated_count) + }; + + Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } /// Approve a proposed transaction diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 65a25230..22269681 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-01-28, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-01-29, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -29,12 +29,13 @@ // benchmark // pallet // --chain=dev +// --wasm-execution=compiled // --pallet=pallet_multisig // --extrinsic=* // --steps=50 // --repeat=20 // --output=./pallets/multisig/src/weights.rs -// --template=./.maintain/frame-weight-template.hbs +// --template=.maintain/frame-weight-template.hbs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -49,6 +50,7 @@ use core::marker::PhantomData; pub trait WeightInfo { fn create_multisig() -> Weight; fn propose(c: u32, e: u32, ) -> Weight; + fn propose_high_security(c: u32, e: u32, ) -> Weight; fn approve(c: u32, e: u32, ) -> Weight; fn approve_and_execute(c: u32, ) -> Weight; fn cancel(c: u32, e: u32, ) -> Weight; @@ -68,26 +70,52 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 192_000_000 picoseconds. - Weight::from_parts(195_000_000, 10389) + // Minimum execution time: 193_000_000 picoseconds. + Weight::from_parts(196_000_000, 10389) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) + /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn propose(_c: u32, e: u32, ) -> Weight { + fn propose(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `458 + e * (215 ±0)` + // Measured: `610 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 40_000_000 picoseconds. - Weight::from_parts(140_354_473, 17022) - // Standard Error: 30_916 - .saturating_add(Weight::from_parts(14_183_732, 0).saturating_mul(e.into())) - .saturating_add(T::DbWeight::get().reads(2_u64)) + // Minimum execution time: 79_000_000 picoseconds. + Weight::from_parts(81_000_000, 17022) + // Standard Error: 353 + .saturating_add(Weight::from_parts(5_950, 0).saturating_mul(c.into())) + // Standard Error: 17_924 + .saturating_add(Weight::from_parts(14_233_021, 0).saturating_mul(e.into())) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) + .saturating_add(T::DbWeight::get().writes(2_u64)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(e.into()))) + .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) + /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:201 w:201) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + /// The range of component `e` is `[0, 200]`. + fn propose_high_security(_c: u32, e: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `610 + e * (215 ±0)` + // Estimated: `17022 + e * (16032 ±0)` + // Minimum execution time: 75_000_000 picoseconds. + Weight::from_parts(395_129_357, 17022) + // Standard Error: 140_152 + .saturating_add(Weight::from_parts(14_193_976, 0).saturating_mul(e.into())) + .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(e.into()))) @@ -103,10 +131,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `657 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(31_012_674, 33054) - // Standard Error: 25_877 - .saturating_add(Weight::from_parts(13_708_908, 0).saturating_mul(e.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(50_112_514, 33054) + // Standard Error: 23_296 + .saturating_add(Weight::from_parts(13_996_925, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -122,10 +150,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `790 + c * (1 ±0)` // Estimated: `33054` - // Minimum execution time: 29_000_000 picoseconds. - Weight::from_parts(29_907_548, 33054) - // Standard Error: 17 - .saturating_add(Weight::from_parts(782, 0).saturating_mul(c.into())) + // Minimum execution time: 30_000_000 picoseconds. + Weight::from_parts(33_169_141, 33054) + // Standard Error: 42 + .saturating_add(Weight::from_parts(483, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -139,12 +167,12 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `625 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 27_000_000 picoseconds. - Weight::from_parts(22_414_315, 33054) - // Standard Error: 576 - .saturating_add(Weight::from_parts(1_526, 0).saturating_mul(c.into())) - // Standard Error: 29_178 - .saturating_add(Weight::from_parts(13_866_655, 0).saturating_mul(e.into())) + // Minimum execution time: 28_000_000 picoseconds. + Weight::from_parts(54_176_352, 33054) + // Standard Error: 455 + .saturating_add(Weight::from_parts(488, 0).saturating_mul(c.into())) + // Standard Error: 23_037 + .saturating_add(Weight::from_parts(13_776_229, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -159,8 +187,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `764` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -173,10 +201,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` - // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(28_491_742, 17022) - // Standard Error: 16_103 - .saturating_add(Weight::from_parts(13_535_595, 0).saturating_mul(p.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(23_000_000, 17022) + // Standard Error: 11_074 + .saturating_add(Weight::from_parts(13_850_827, 0).saturating_mul(p.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(p.into()))) .saturating_add(T::DbWeight::get().writes(1_u64)) @@ -194,7 +222,7 @@ impl WeightInfo for SubstrateWeight { // Measured: `538` // Estimated: `17022` // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(30_000_000, 17022) + Weight::from_parts(22_000_000, 17022) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -210,26 +238,52 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 192_000_000 picoseconds. - Weight::from_parts(195_000_000, 10389) + // Minimum execution time: 193_000_000 picoseconds. + Weight::from_parts(196_000_000, 10389) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) + /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn propose(_c: u32, e: u32, ) -> Weight { + fn propose(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `458 + e * (215 ±0)` + // Measured: `610 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 40_000_000 picoseconds. - Weight::from_parts(140_354_473, 17022) - // Standard Error: 30_916 - .saturating_add(Weight::from_parts(14_183_732, 0).saturating_mul(e.into())) - .saturating_add(RocksDbWeight::get().reads(2_u64)) + // Minimum execution time: 79_000_000 picoseconds. + Weight::from_parts(81_000_000, 17022) + // Standard Error: 353 + .saturating_add(Weight::from_parts(5_950, 0).saturating_mul(c.into())) + // Standard Error: 17_924 + .saturating_add(Weight::from_parts(14_233_021, 0).saturating_mul(e.into())) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(e.into()))) + .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) + /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:201 w:201) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + /// The range of component `e` is `[0, 200]`. + fn propose_high_security(_c: u32, e: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `610 + e * (215 ±0)` + // Estimated: `17022 + e * (16032 ±0)` + // Minimum execution time: 75_000_000 picoseconds. + Weight::from_parts(395_129_357, 17022) + // Standard Error: 140_152 + .saturating_add(Weight::from_parts(14_193_976, 0).saturating_mul(e.into())) + .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(e.into()))) @@ -245,10 +299,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `657 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(31_012_674, 33054) - // Standard Error: 25_877 - .saturating_add(Weight::from_parts(13_708_908, 0).saturating_mul(e.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(50_112_514, 33054) + // Standard Error: 23_296 + .saturating_add(Weight::from_parts(13_996_925, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -264,10 +318,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `790 + c * (1 ±0)` // Estimated: `33054` - // Minimum execution time: 29_000_000 picoseconds. - Weight::from_parts(29_907_548, 33054) - // Standard Error: 17 - .saturating_add(Weight::from_parts(782, 0).saturating_mul(c.into())) + // Minimum execution time: 30_000_000 picoseconds. + Weight::from_parts(33_169_141, 33054) + // Standard Error: 42 + .saturating_add(Weight::from_parts(483, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -281,12 +335,12 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `625 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 27_000_000 picoseconds. - Weight::from_parts(22_414_315, 33054) - // Standard Error: 576 - .saturating_add(Weight::from_parts(1_526, 0).saturating_mul(c.into())) - // Standard Error: 29_178 - .saturating_add(Weight::from_parts(13_866_655, 0).saturating_mul(e.into())) + // Minimum execution time: 28_000_000 picoseconds. + Weight::from_parts(54_176_352, 33054) + // Standard Error: 455 + .saturating_add(Weight::from_parts(488, 0).saturating_mul(c.into())) + // Standard Error: 23_037 + .saturating_add(Weight::from_parts(13_776_229, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -301,8 +355,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `764` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -315,10 +369,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` - // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(28_491_742, 17022) - // Standard Error: 16_103 - .saturating_add(Weight::from_parts(13_535_595, 0).saturating_mul(p.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(23_000_000, 17022) + // Standard Error: 11_074 + .saturating_add(Weight::from_parts(13_850_827, 0).saturating_mul(p.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(p.into()))) .saturating_add(RocksDbWeight::get().writes(1_u64)) @@ -336,7 +390,7 @@ impl WeightInfo for () { // Measured: `538` // Estimated: `17022` // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(30_000_000, 17022) + Weight::from_parts(22_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } diff --git a/pallets/reversible-transfers/README.md b/pallets/reversible-transfers/README.md index 62ca845a..31d1cc65 100644 --- a/pallets/reversible-transfers/README.md +++ b/pallets/reversible-transfers/README.md @@ -1,6 +1,14 @@ -### Motivation +# Reversible Transfers Pallet -To have accounts for which all outgoing transfer are subject to a variable time during which they may be cancelled. The idea is this could be used to deter theft as well as correct mistakes. +## Motivation + +To have accounts for which all outgoing transfers are subject to a variable time during which they may be cancelled. The idea is this could be used to deter theft as well as correct mistakes. + +**Use Cases:** +- **High-security custody:** Corporate treasury with guardian oversight +- **Mistake recovery:** Cancel accidental transfers during delay period +- **Theft deterrence:** Guardian can cancel suspicious transfers before execution +- **Regulatory compliance:** Time-delayed transfers with oversight capabilities ## Design @@ -32,3 +40,119 @@ Pending/delayed transfers can be tracked at `PendingTransfers` storage and by su ### Notes - Transaction id is `((who, call).hash())` where `who` is the account that called the transaction and `call` is the call itself. This is used to identify the transaction in the scheduler and preimage. For identical transfers, there is a counter in `PendingTransfer` to differentiate between them. + +## High-Security Integration + +This pallet provides the **HighSecurityInspector** trait for integrating high-security features with other pallets (like `pallet-multisig`). + +### HighSecurityInspector Trait + +```rust +pub trait HighSecurityInspector { + /// Check if account is registered as high-security + fn is_high_security(who: &AccountId) -> bool; + + /// Check if call is whitelisted for high-security accounts + fn is_whitelisted(call: &RuntimeCall) -> bool; + + /// Get guardian account for high-security account (if exists) + fn guardian(who: &AccountId) -> Option; +} +``` + +**Purpose:** +- Provides unified interface for high-security checks +- Used by `pallet-multisig` for call whitelisting +- Used by transaction extensions for EOA whitelisting +- Implemented by runtime for call pattern matching + +### Implementation + +**This pallet provides:** +- Trait definition (`pub trait HighSecurityInspector`) +- Helper functions for runtime implementation: + - `is_high_security_account(who)` - checks `HighSecurityAccounts` storage + - `get_guardian(who)` - retrieves guardian from storage +- Default no-op implementation: `impl HighSecurityInspector for ()` + +**Runtime implements:** +- The actual `is_whitelisted(call)` logic (requires `RuntimeCall` access) +- Delegates `is_high_security` and `guardian` to pallet helper functions +- Example: + +```rust +pub struct HighSecurityConfig; + +impl HighSecurityInspector for HighSecurityConfig { + fn is_high_security(who: &AccountId) -> bool { + // Delegate to pallet helper + ReversibleTransfers::is_high_security_account(who) + } + + fn is_whitelisted(call: &RuntimeCall) -> bool { + // Runtime implements pattern matching (has RuntimeCall access) + matches!( + call, + RuntimeCall::ReversibleTransfers(Call::schedule_transfer { .. }) | + RuntimeCall::ReversibleTransfers(Call::schedule_asset_transfer { .. }) | + RuntimeCall::ReversibleTransfers(Call::cancel { .. }) + ) + } + + fn guardian(who: &AccountId) -> Option { + // Delegate to pallet helper + ReversibleTransfers::get_guardian(who) + } +} +``` + +### Usage by Other Pallets + +**pallet-multisig:** +```rust +impl pallet_multisig::Config for Runtime { + type HighSecurity = HighSecurityConfig; + // ... +} + +// In multisig propose(): +if T::HighSecurity::is_high_security(&multisig_address) { + let decoded_call = RuntimeCall::decode(&call)?; + ensure!( + T::HighSecurity::is_whitelisted(&decoded_call), + Error::CallNotAllowedForHighSecurityMultisig + ); +} +``` + +**Transaction Extensions:** +```rust +// In ReversibleTransactionExtension::validate(): +if HighSecurityConfig::is_high_security(&who) { + ensure!( + HighSecurityConfig::is_whitelisted(&call), + TransactionValidityError::Invalid(InvalidTransaction::Call) + ); +} +``` + +### Architecture Benefits + +**Single Source of Truth:** +- Whitelist defined once in runtime +- Used by multisig, transaction extensions, and any future consumers +- Easy to maintain and update + +**Modularity:** +- Trait defined in this pallet (storage owner) +- Implementation in runtime (has `RuntimeCall` access) +- Consumers use trait without coupling to implementation + +**Reusability:** +- Same security model for EOAs and multisigs +- Consistent whitelist enforcement across all account types +- Easy to add new consumers (just use the trait) + +### Documentation + +See `MULTISIG_REQ.md` for complete high-security integration architecture and examples. From b82ff3d4cb61f06778a60a781aa61a95063525c6 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Thu, 29 Jan 2026 16:13:42 +0800 Subject: [PATCH 03/15] fix: Benchmarks + README --- pallets/multisig/README.md | 65 +++++++++- pallets/multisig/src/benchmarking.rs | 24 ++-- pallets/multisig/src/lib.rs | 11 +- pallets/multisig/src/weights.rs | 150 ++++++++++++------------ pallets/reversible-transfers/src/lib.rs | 10 ++ runtime/src/configs/mod.rs | 22 ++++ 6 files changed, 189 insertions(+), 93 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index bd49e4b7..cde1760f 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -525,6 +525,53 @@ The multisig pallet integrates with **pallet-reversible-transfers** to support h - **Guardian oversight:** Guardian can cancel during delay period - **Use case:** Corporate treasury, regulated operations, high-value custody +### ⚠️ Important: Enabling High-Security + +**Risk Window:** +When enabling high-security for an existing multisig with active proposals: +1. **Existing proposals** are NOT automatically blocked +2. **Whitelist check** only happens at proposal creation time (`propose()`) +3. **Proposals created before HS** can still be executed after HS is enabled + +**Mitigation:** +Before enabling high-security, ensure: +- ✅ All active proposals are **completed** (executed or cancelled) +- ✅ All proposals have **expired** or been **removed** +- ✅ No pending approvals exist + +**Safe workflow:** +```rust +// 1. Check for active proposals +let proposals = query_proposals(multisig_address); +assert_eq!(proposals.len(), 0, "Must cleanup proposals first"); + +// 2. Cancel or wait for expiry +for proposal_id in proposals { + Multisig::cancel(Origin::signed(proposer), multisig_address, proposal_id); + // OR: wait for expiry +} + +// 3. NOW enable high-security +ReversibleTransfers::set_high_security( + Origin::signed(multisig_address), + delay: 100_800, + guardian: guardian_account +); +``` + +**Why this design:** +- **Simplicity:** Single check point (`propose`) easier to reason about +- **Gas efficiency:** No decode overhead on every approval +- **User control:** Explicit transition management +- **Trade-off:** Performance and simplicity over defense-in-depth + +**Could be changed:** +Adding whitelist check in `approve()` (before execution) would close this window, +at the cost of: +- Higher gas on every approval for HS multisigs (~70M units for decode + check) +- More complex execution path +- Would make this a non-issue + ### How It Works 1. **Setup:** Multisig account calls `ReversibleTransfers::set_high_security(delay, guardian)` @@ -585,15 +632,25 @@ Multisig::propose( ### Performance Impact -High-security multisigs have slightly higher costs due to call validation: +High-security multisigs have higher costs due to call validation: - **+1 DB read:** Check `ReversibleTransfers::HighSecurityAccounts` -- **+Decode overhead:** ~50k units per call byte +- **+Decode overhead:** Variable cost based on call size (O(call_size)) - **+Whitelist check:** ~10k units for pattern matching -- **Total overhead:** ~25M base + 50k/byte for typical 1KB call +- **Total overhead:** Base cost + decode cost proportional to call size **Dynamic weight refund:** -Normal multisigs automatically get refunded for unused high-security overhead (typically 92-94% refund). +Normal multisigs automatically get refunded for unused high-security overhead. + +**Weight calculation:** +- `propose()` charges upfront for worst-case: `propose_high_security(call.len(), max_expired)` +- If multisig is NOT HS, refunds decode overhead based on actual path taken +- If multisig IS HS, charges correctly for decode cost (scales with call size) + +**Security notes:** +- Call size is validated BEFORE decode to prevent DoS via oversized payloads +- Weight formula includes O(call_size) component for decode to prevent underpayment +- Benchmarks must be regenerated to capture accurate decode costs See `MULTISIG_REQ.md` for detailed cost breakdown and benchmarking instructions. diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index b19af052..64b6b614 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -179,11 +179,13 @@ mod benchmarks { }; Multisigs::::insert(&multisig_address, multisig_data); - // IMPORTANT: Set this multisig as high-security - // Note: This is a mock setup - in production, HighSecurity implementation - // would check reversible-transfers storage. For benchmarking, we assume - // the HighSecurity type implementation recognizes this multisig as HS. - // The benchmark measures the worst-case: decode + whitelist check. + // IMPORTANT: Set this multisig as high-security for benchmarking + // This ensures we measure the actual HS code path: + // - is_high_security() will return true + // - propose() will decode the call and check whitelist + // - This adds ~25M base + ~50k/byte overhead vs normal propose + #[cfg(feature = "runtime-benchmarks")] + T::HighSecurity::set_high_security_for_benchmarking(&multisig_address); // Insert e expired proposals (worst case for auto-cleanup) let expired_block = 10u32.into(); @@ -208,12 +210,18 @@ mod benchmarks { // Move past expiry so proposals are expired frame_system::Pallet::::set_block_number(100u32.into()); - // Create a whitelisted call for high-security (system::remark as placeholder) - // In production this would be ReversibleTransfers::schedule_transfer - // But for benchmarking we measure the decode overhead with system::remark of size c + // Create a whitelisted call for high-security + // IMPORTANT: Use remark with variable size 'c' to measure decode overhead + // The benchmark must vary the call size to properly measure O(c) decode cost + // system::remark is used as proxy - in production this would be + // ReversibleTransfers::schedule_transfer let system_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; let call = ::RuntimeCall::from(system_call); let encoded_call = call.encode(); + + // Verify we're testing with actual variable size + assert!(encoded_call.len() >= c as usize, "Call size should scale with parameter c"); + let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); #[extrinsic_call] diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 99fbd441..af140a5e 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -518,12 +518,18 @@ pub mod pallet { ) -> DispatchResultWithPostInfo { let proposer = ensure_signed(origin)?; + // CRITICAL: Check call size FIRST, before any heavy operations (especially decode) + // This prevents DoS via oversized payloads that would be decoded before size validation + let call_size = call.len() as u32; + ensure!(call_size <= T::MaxCallSize::get(), Error::::CallTooLarge); + // Check if proposer is a signer let multisig_data = Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; ensure!(multisig_data.signers.contains(&proposer), Error::::NotASigner); // High-security check: if multisig is high-security, only whitelisted calls allowed + // Size already validated above, so decode is now safe let is_high_security = T::HighSecurity::is_high_security(&multisig_address); if is_high_security { let decoded_call = ::RuntimeCall::decode(&mut &call[..]) @@ -610,10 +616,7 @@ pub mod pallet { } }); - // Store call length before moving it (needed for weight calculation) - let call_size = call.len() as u32; - - // Convert to bounded vec + // Convert to bounded vec (call_size already computed and validated above) let bounded_call: BoundedCallOf = call.try_into().map_err(|_| Error::::CallTooLarge)?; diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 22269681..5fe354de 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -34,8 +34,8 @@ // --extrinsic=* // --steps=50 // --repeat=20 +// --template=./.maintain/frame-weight-template.hbs // --output=./pallets/multisig/src/weights.rs -// --template=.maintain/frame-weight-template.hbs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -70,8 +70,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 193_000_000 picoseconds. - Weight::from_parts(196_000_000, 10389) + // Minimum execution time: 228_000_000 picoseconds. + Weight::from_parts(250_000_000, 10389) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -83,16 +83,14 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn propose(c: u32, e: u32, ) -> Weight { + fn propose(_c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `610 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 79_000_000 picoseconds. - Weight::from_parts(81_000_000, 17022) - // Standard Error: 353 - .saturating_add(Weight::from_parts(5_950, 0).saturating_mul(c.into())) - // Standard Error: 17_924 - .saturating_add(Weight::from_parts(14_233_021, 0).saturating_mul(e.into())) + // Minimum execution time: 87_000_000 picoseconds. + Weight::from_parts(800_288_659, 17022) + // Standard Error: 193_724 + .saturating_add(Weight::from_parts(14_421_203, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -109,12 +107,12 @@ impl WeightInfo for SubstrateWeight { /// The range of component `e` is `[0, 200]`. fn propose_high_security(_c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `610 + e * (215 ±0)` + // Measured: `770 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 75_000_000 picoseconds. - Weight::from_parts(395_129_357, 17022) - // Standard Error: 140_152 - .saturating_add(Weight::from_parts(14_193_976, 0).saturating_mul(e.into())) + // Minimum execution time: 48_000_000 picoseconds. + Weight::from_parts(77_592_134, 17022) + // Standard Error: 31_780 + .saturating_add(Weight::from_parts(14_409_002, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -127,14 +125,16 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn approve(_c: u32, e: u32, ) -> Weight { + fn approve(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `657 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(50_112_514, 33054) - // Standard Error: 23_296 - .saturating_add(Weight::from_parts(13_996_925, 0).saturating_mul(e.into())) + // Minimum execution time: 24_000_000 picoseconds. + Weight::from_parts(27_069_498, 33054) + // Standard Error: 487 + .saturating_add(Weight::from_parts(191, 0).saturating_mul(c.into())) + // Standard Error: 24_658 + .saturating_add(Weight::from_parts(14_006_816, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -150,10 +150,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `790 + c * (1 ±0)` // Estimated: `33054` - // Minimum execution time: 30_000_000 picoseconds. - Weight::from_parts(33_169_141, 33054) - // Standard Error: 42 - .saturating_add(Weight::from_parts(483, 0).saturating_mul(c.into())) + // Minimum execution time: 29_000_000 picoseconds. + Weight::from_parts(36_252_020, 33054) + // Standard Error: 76 + .saturating_add(Weight::from_parts(615, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -163,16 +163,14 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn cancel(c: u32, e: u32, ) -> Weight { + fn cancel(_c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `625 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 28_000_000 picoseconds. - Weight::from_parts(54_176_352, 33054) - // Standard Error: 455 - .saturating_add(Weight::from_parts(488, 0).saturating_mul(c.into())) - // Standard Error: 23_037 - .saturating_add(Weight::from_parts(13_776_229, 0).saturating_mul(e.into())) + // Minimum execution time: 27_000_000 picoseconds. + Weight::from_parts(31_511_446, 33054) + // Standard Error: 22_515 + .saturating_add(Weight::from_parts(14_026_107, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -187,8 +185,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `764` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(25_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -201,10 +199,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) - // Standard Error: 11_074 - .saturating_add(Weight::from_parts(13_850_827, 0).saturating_mul(p.into())) + // Minimum execution time: 23_000_000 picoseconds. + Weight::from_parts(33_197_740, 17022) + // Standard Error: 25_852 + .saturating_add(Weight::from_parts(13_770_988, 0).saturating_mul(p.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(p.into()))) .saturating_add(T::DbWeight::get().writes(1_u64)) @@ -221,8 +219,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `538` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(23_000_000, 17022) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -238,8 +236,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 193_000_000 picoseconds. - Weight::from_parts(196_000_000, 10389) + // Minimum execution time: 228_000_000 picoseconds. + Weight::from_parts(250_000_000, 10389) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -251,16 +249,14 @@ impl WeightInfo for () { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn propose(c: u32, e: u32, ) -> Weight { + fn propose(_c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `610 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 79_000_000 picoseconds. - Weight::from_parts(81_000_000, 17022) - // Standard Error: 353 - .saturating_add(Weight::from_parts(5_950, 0).saturating_mul(c.into())) - // Standard Error: 17_924 - .saturating_add(Weight::from_parts(14_233_021, 0).saturating_mul(e.into())) + // Minimum execution time: 87_000_000 picoseconds. + Weight::from_parts(800_288_659, 17022) + // Standard Error: 193_724 + .saturating_add(Weight::from_parts(14_421_203, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -277,12 +273,12 @@ impl WeightInfo for () { /// The range of component `e` is `[0, 200]`. fn propose_high_security(_c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `610 + e * (215 ±0)` + // Measured: `770 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 75_000_000 picoseconds. - Weight::from_parts(395_129_357, 17022) - // Standard Error: 140_152 - .saturating_add(Weight::from_parts(14_193_976, 0).saturating_mul(e.into())) + // Minimum execution time: 48_000_000 picoseconds. + Weight::from_parts(77_592_134, 17022) + // Standard Error: 31_780 + .saturating_add(Weight::from_parts(14_409_002, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -295,14 +291,16 @@ impl WeightInfo for () { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn approve(_c: u32, e: u32, ) -> Weight { + fn approve(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `657 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(50_112_514, 33054) - // Standard Error: 23_296 - .saturating_add(Weight::from_parts(13_996_925, 0).saturating_mul(e.into())) + // Minimum execution time: 24_000_000 picoseconds. + Weight::from_parts(27_069_498, 33054) + // Standard Error: 487 + .saturating_add(Weight::from_parts(191, 0).saturating_mul(c.into())) + // Standard Error: 24_658 + .saturating_add(Weight::from_parts(14_006_816, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -318,10 +316,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `790 + c * (1 ±0)` // Estimated: `33054` - // Minimum execution time: 30_000_000 picoseconds. - Weight::from_parts(33_169_141, 33054) - // Standard Error: 42 - .saturating_add(Weight::from_parts(483, 0).saturating_mul(c.into())) + // Minimum execution time: 29_000_000 picoseconds. + Weight::from_parts(36_252_020, 33054) + // Standard Error: 76 + .saturating_add(Weight::from_parts(615, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -331,16 +329,14 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn cancel(c: u32, e: u32, ) -> Weight { + fn cancel(_c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `625 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 28_000_000 picoseconds. - Weight::from_parts(54_176_352, 33054) - // Standard Error: 455 - .saturating_add(Weight::from_parts(488, 0).saturating_mul(c.into())) - // Standard Error: 23_037 - .saturating_add(Weight::from_parts(13_776_229, 0).saturating_mul(e.into())) + // Minimum execution time: 27_000_000 picoseconds. + Weight::from_parts(31_511_446, 33054) + // Standard Error: 22_515 + .saturating_add(Weight::from_parts(14_026_107, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -355,8 +351,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `764` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(25_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -369,10 +365,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) - // Standard Error: 11_074 - .saturating_add(Weight::from_parts(13_850_827, 0).saturating_mul(p.into())) + // Minimum execution time: 23_000_000 picoseconds. + Weight::from_parts(33_197_740, 17022) + // Standard Error: 25_852 + .saturating_add(Weight::from_parts(13_770_988, 0).saturating_mul(p.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(p.into()))) .saturating_add(RocksDbWeight::get().writes(1_u64)) @@ -389,8 +385,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `538` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(23_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } diff --git a/pallets/reversible-transfers/src/lib.rs b/pallets/reversible-transfers/src/lib.rs index ec8d4463..0122ab7f 100644 --- a/pallets/reversible-transfers/src/lib.rs +++ b/pallets/reversible-transfers/src/lib.rs @@ -47,6 +47,11 @@ pub trait HighSecurityInspector { fn is_whitelisted(call: &RuntimeCall) -> bool; /// Get guardian for high-security account fn guardian(who: &AccountId) -> Option; + + /// Set account as high-security for benchmarking purposes only + /// This allows benchmarks to measure the HS code path without full runtime setup + #[cfg(feature = "runtime-benchmarks")] + fn set_high_security_for_benchmarking(who: &AccountId); } /// Default implementation for HighSecurityInspector (no high-security) @@ -61,6 +66,11 @@ impl HighSecurityInspector for ( fn guardian(_who: &AccountId) -> Option { None } + + #[cfg(feature = "runtime-benchmarks")] + fn set_high_security_for_benchmarking(_who: &AccountId) { + // No-op for default implementation + } } // Partial implementation for Pallet - runtime will complete it diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index fd6da282..04eba988 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -602,6 +602,15 @@ impl pallet_reversible_transfers::HighSecurityInspector fn is_whitelisted(call: &RuntimeCall) -> bool { // Runtime-level whitelist: only reversible-transfers operations allowed + #[cfg(feature = "runtime-benchmarks")] + { + // For benchmarking: allow system::remark to measure decode overhead + // without side effects from actual transfer operations + if matches!(call, RuntimeCall::System(frame_system::Call::remark { .. })) { + return true; + } + } + matches!( call, RuntimeCall::ReversibleTransfers( @@ -616,6 +625,19 @@ impl pallet_reversible_transfers::HighSecurityInspector // Delegate to reversible-transfers pallet pallet_reversible_transfers::Pallet::::get_guardian(who) } + + #[cfg(feature = "runtime-benchmarks")] + fn set_high_security_for_benchmarking(who: &AccountId) { + use pallet_reversible_transfers::{HighSecurityAccountData, HighSecurityAccounts}; + use qp_scheduler::BlockNumberOrTimestamp; + + // Insert dummy HS data for benchmarking + let hs_data = HighSecurityAccountData { + interceptor: who.clone(), + delay: BlockNumberOrTimestamp::BlockNumber(100u32), + }; + HighSecurityAccounts::::insert(who, hs_data); + } } impl pallet_multisig::Config for Runtime { From 95a8a2bf435c64dc3fa70c8c552357704aba3685 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Fri, 30 Jan 2026 11:41:19 +0800 Subject: [PATCH 04/15] feat: HS trait defined in primitives --- Cargo.lock | 12 ++ pallets/multisig/Cargo.toml | 14 +- pallets/multisig/README.md | 3 +- pallets/multisig/src/benchmarking.rs | 31 ++-- pallets/multisig/src/lib.rs | 6 +- pallets/multisig/src/tests.rs | 2 +- pallets/multisig/src/weights.rs | 143 ++++++++-------- pallets/reversible-transfers/Cargo.toml | 3 + .../reversible-transfers/src/benchmarking.rs | 11 ++ pallets/reversible-transfers/src/lib.rs | 38 +---- pallets/reversible-transfers/src/weights.rs | 72 ++++---- primitives/high-security/Cargo.toml | 22 +++ primitives/high-security/src/lib.rs | 156 ++++++++++++++++++ runtime/Cargo.toml | 2 + runtime/src/configs/mod.rs | 20 +-- runtime/src/transaction_extensions.rs | 2 +- 16 files changed, 355 insertions(+), 182 deletions(-) create mode 100644 primitives/high-security/Cargo.toml create mode 100644 primitives/high-security/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index a8c52217..b7b77e3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7182,6 +7182,8 @@ dependencies = [ "pallet-reversible-transfers", "pallet-timestamp", "parity-scale-codec", + "qp-high-security", + "qp-scheduler", "scale-info", "sp-arithmetic", "sp-core", @@ -7294,6 +7296,7 @@ dependencies = [ "pallet-timestamp", "pallet-utility", "parity-scale-codec", + "qp-high-security", "qp-scheduler", "scale-info", "sp-arithmetic", @@ -8885,6 +8888,14 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "qp-high-security" +version = "0.1.0" +dependencies = [ + "parity-scale-codec", + "scale-info", +] + [[package]] name = "qp-plonky2" version = "1.1.1" @@ -9195,6 +9206,7 @@ dependencies = [ "primitive-types 0.13.1", "qp-dilithium-crypto", "qp-header", + "qp-high-security", "qp-poseidon", "qp-scheduler", "scale-info", diff --git a/pallets/multisig/Cargo.toml b/pallets/multisig/Cargo.toml index 34f2389a..f89429d8 100644 --- a/pallets/multisig/Cargo.toml +++ b/pallets/multisig/Cargo.toml @@ -18,7 +18,9 @@ frame-support.workspace = true frame-system.workspace = true log.workspace = true pallet-balances.workspace = true -pallet-reversible-transfers = { path = "../reversible-transfers", default-features = false } +pallet-reversible-transfers = { path = "../reversible-transfers", default-features = false, optional = true } +qp-high-security = { path = "../../primitives/high-security", default-features = false } +qp-scheduler = { workspace = true, optional = true } scale-info = { features = ["derive"], workspace = true } sp-arithmetic.workspace = true sp-core.workspace = true @@ -39,7 +41,10 @@ runtime-benchmarks = [ "frame-support/runtime-benchmarks", "frame-system/runtime-benchmarks", "pallet-balances/runtime-benchmarks", - "pallet-reversible-transfers/runtime-benchmarks", + "pallet-reversible-transfers", + "pallet-reversible-transfers?/runtime-benchmarks", + "qp-high-security/runtime-benchmarks", + "qp-scheduler", "sp-runtime/runtime-benchmarks", ] std = [ @@ -49,8 +54,10 @@ std = [ "frame-system/std", "log/std", "pallet-balances/std", - "pallet-reversible-transfers/std", + "pallet-reversible-transfers?/std", "pallet-timestamp/std", + "qp-high-security/std", + "qp-scheduler?/std", "scale-info/std", "sp-arithmetic/std", "sp-core/std", @@ -60,6 +67,5 @@ std = [ try-runtime = [ "frame-support/try-runtime", "frame-system/try-runtime", - "pallet-reversible-transfers/try-runtime", "sp-runtime/try-runtime", ] diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index cde1760f..95bac3fe 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -663,8 +663,9 @@ impl pallet_multisig::Config for Runtime { } // Runtime implements HighSecurityInspector trait +// (trait defined in primitives/high-security crate) pub struct HighSecurityConfig; -impl HighSecurityInspector for HighSecurityConfig { +impl qp_high_security::HighSecurityInspector for HighSecurityConfig { fn is_high_security(who: &AccountId) -> bool { ReversibleTransfers::is_high_security_account(who) } diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 64b6b614..2ed1ff71 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -24,7 +24,7 @@ where #[benchmarks( where - T: Config + pallet_balances::Config, + T: Config + pallet_balances::Config + pallet_reversible_transfers::Config, BalanceOf2: From, )] mod benchmarks { @@ -180,12 +180,21 @@ mod benchmarks { Multisigs::::insert(&multisig_address, multisig_data); // IMPORTANT: Set this multisig as high-security for benchmarking - // This ensures we measure the actual HS code path: - // - is_high_security() will return true - // - propose() will decode the call and check whitelist - // - This adds ~25M base + ~50k/byte overhead vs normal propose + // This ensures we measure the actual HS code path #[cfg(feature = "runtime-benchmarks")] - T::HighSecurity::set_high_security_for_benchmarking(&multisig_address); + { + use pallet_reversible_transfers::{ + benchmarking::insert_hs_account_for_benchmark, HighSecurityAccountData, + }; + use qp_scheduler::BlockNumberOrTimestamp; + + let hs_data = HighSecurityAccountData { + interceptor: multisig_address.clone(), + delay: BlockNumberOrTimestamp::BlockNumber(100u32.into()), + }; + // Use helper that accepts T: pallet_reversible_transfers::Config + insert_hs_account_for_benchmark::(multisig_address.clone(), hs_data); + } // Insert e expired proposals (worst case for auto-cleanup) let expired_block = 10u32.into(); @@ -210,11 +219,9 @@ mod benchmarks { // Move past expiry so proposals are expired frame_system::Pallet::::set_block_number(100u32.into()); - // Create a whitelisted call for high-security - // IMPORTANT: Use remark with variable size 'c' to measure decode overhead - // The benchmark must vary the call size to properly measure O(c) decode cost - // system::remark is used as proxy - in production this would be - // ReversibleTransfers::schedule_transfer + // Create a whitelisted call for HS multisig + // Using system::remark with variable size to measure decode cost O(c) + // NOTE: system::remark is whitelisted ONLY in runtime-benchmarks mode let system_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; let call = ::RuntimeCall::from(system_call); let encoded_call = call.encode(); @@ -642,7 +649,7 @@ mod benchmarks { let deposit = T::MultisigDeposit::get(); // Reserve deposit from caller - T::Currency::reserve(&caller, deposit)?; + ::Currency::reserve(&caller, deposit)?; let multisig_data = MultisigDataOf:: { signers: bounded_signers, diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index af140a5e..061e7e0f 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -132,6 +132,7 @@ pub mod pallet { PalletId, }; use frame_system::pallet_prelude::*; + use qp_high_security::HighSecurityInspector; use sp_arithmetic::traits::Saturating; use sp_runtime::{ traits::{Dispatchable, Hash, TrailingZeroInput}, @@ -207,15 +208,12 @@ pub mod pallet { type WeightInfo: WeightInfo; /// Interface to check if an account is in high-security mode - type HighSecurity: pallet_reversible_transfers::HighSecurityInspector< + type HighSecurity: qp_high_security::HighSecurityInspector< Self::AccountId, ::RuntimeCall, >; } - /// Re-export HighSecurityInspector trait from reversible-transfers for convenience - pub use pallet_reversible_transfers::HighSecurityInspector; - /// Type alias for bounded signers vector pub type BoundedSignersOf = BoundedVec<::AccountId, ::MaxSigners>; diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 792432dc..16b28ace 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -3,7 +3,7 @@ use crate::{mock::*, Error, Event, GlobalNonce, Multisigs, ProposalStatus, Proposals}; use codec::Encode; use frame_support::{assert_noop, assert_ok, traits::fungible::Mutate}; -use pallet_reversible_transfers::HighSecurityInspector; +use qp_high_security::HighSecurityInspector; use sp_core::crypto::AccountId32; /// Mock implementation for HighSecurityInspector diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 5fe354de..13baa5ba 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-01-29, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-01-30, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -29,7 +29,6 @@ // benchmark // pallet // --chain=dev -// --wasm-execution=compiled // --pallet=pallet_multisig // --extrinsic=* // --steps=50 @@ -70,8 +69,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 228_000_000 picoseconds. - Weight::from_parts(250_000_000, 10389) + // Minimum execution time: 190_000_000 picoseconds. + Weight::from_parts(196_000_000, 10389) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -83,14 +82,16 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn propose(_c: u32, e: u32, ) -> Weight { + fn propose(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `610 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 87_000_000 picoseconds. - Weight::from_parts(800_288_659, 17022) - // Standard Error: 193_724 - .saturating_add(Weight::from_parts(14_421_203, 0).saturating_mul(e.into())) + // Minimum execution time: 77_000_000 picoseconds. + Weight::from_parts(61_728_409, 17022) + // Standard Error: 508 + .saturating_add(Weight::from_parts(3_081, 0).saturating_mul(c.into())) + // Standard Error: 25_716 + .saturating_add(Weight::from_parts(14_354_502, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -105,14 +106,16 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn propose_high_security(_c: u32, e: u32, ) -> Weight { + fn propose_high_security(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `770 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 48_000_000 picoseconds. - Weight::from_parts(77_592_134, 17022) - // Standard Error: 31_780 - .saturating_add(Weight::from_parts(14_409_002, 0).saturating_mul(e.into())) + // Minimum execution time: 46_000_000 picoseconds. + Weight::from_parts(46_636_454, 17022) + // Standard Error: 504 + .saturating_add(Weight::from_parts(282, 0).saturating_mul(c.into())) + // Standard Error: 25_536 + .saturating_add(Weight::from_parts(14_620_974, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -125,16 +128,14 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn approve(c: u32, e: u32, ) -> Weight { + fn approve(_c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `657 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 24_000_000 picoseconds. - Weight::from_parts(27_069_498, 33054) - // Standard Error: 487 - .saturating_add(Weight::from_parts(191, 0).saturating_mul(c.into())) - // Standard Error: 24_658 - .saturating_add(Weight::from_parts(14_006_816, 0).saturating_mul(e.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(49_516_534, 33054) + // Standard Error: 26_699 + .saturating_add(Weight::from_parts(14_041_478, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -150,10 +151,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `790 + c * (1 ±0)` // Estimated: `33054` - // Minimum execution time: 29_000_000 picoseconds. - Weight::from_parts(36_252_020, 33054) - // Standard Error: 76 - .saturating_add(Weight::from_parts(615, 0).saturating_mul(c.into())) + // Minimum execution time: 30_000_000 picoseconds. + Weight::from_parts(35_686_901, 33054) + // Standard Error: 95 + .saturating_add(Weight::from_parts(1_060, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -163,14 +164,16 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn cancel(_c: u32, e: u32, ) -> Weight { + fn cancel(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `625 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 27_000_000 picoseconds. - Weight::from_parts(31_511_446, 33054) - // Standard Error: 22_515 - .saturating_add(Weight::from_parts(14_026_107, 0).saturating_mul(e.into())) + // Minimum execution time: 28_000_000 picoseconds. + Weight::from_parts(33_000_000, 33054) + // Standard Error: 229 + .saturating_add(Weight::from_parts(3_462, 0).saturating_mul(c.into())) + // Standard Error: 11_632 + .saturating_add(Weight::from_parts(13_752_458, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -186,7 +189,7 @@ impl WeightInfo for SubstrateWeight { // Measured: `764` // Estimated: `17022` // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(25_000_000, 17022) + Weight::from_parts(26_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -200,9 +203,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(33_197_740, 17022) - // Standard Error: 25_852 - .saturating_add(Weight::from_parts(13_770_988, 0).saturating_mul(p.into())) + Weight::from_parts(27_178_826, 17022) + // Standard Error: 23_441 + .saturating_add(Weight::from_parts(13_739_383, 0).saturating_mul(p.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(p.into()))) .saturating_add(T::DbWeight::get().writes(1_u64)) @@ -220,7 +223,7 @@ impl WeightInfo for SubstrateWeight { // Measured: `538` // Estimated: `17022` // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) + Weight::from_parts(26_000_000, 17022) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -236,8 +239,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 228_000_000 picoseconds. - Weight::from_parts(250_000_000, 10389) + // Minimum execution time: 190_000_000 picoseconds. + Weight::from_parts(196_000_000, 10389) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -249,14 +252,16 @@ impl WeightInfo for () { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn propose(_c: u32, e: u32, ) -> Weight { + fn propose(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `610 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 87_000_000 picoseconds. - Weight::from_parts(800_288_659, 17022) - // Standard Error: 193_724 - .saturating_add(Weight::from_parts(14_421_203, 0).saturating_mul(e.into())) + // Minimum execution time: 77_000_000 picoseconds. + Weight::from_parts(61_728_409, 17022) + // Standard Error: 508 + .saturating_add(Weight::from_parts(3_081, 0).saturating_mul(c.into())) + // Standard Error: 25_716 + .saturating_add(Weight::from_parts(14_354_502, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -271,14 +276,16 @@ impl WeightInfo for () { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn propose_high_security(_c: u32, e: u32, ) -> Weight { + fn propose_high_security(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `770 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 48_000_000 picoseconds. - Weight::from_parts(77_592_134, 17022) - // Standard Error: 31_780 - .saturating_add(Weight::from_parts(14_409_002, 0).saturating_mul(e.into())) + // Minimum execution time: 46_000_000 picoseconds. + Weight::from_parts(46_636_454, 17022) + // Standard Error: 504 + .saturating_add(Weight::from_parts(282, 0).saturating_mul(c.into())) + // Standard Error: 25_536 + .saturating_add(Weight::from_parts(14_620_974, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -291,16 +298,14 @@ impl WeightInfo for () { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn approve(c: u32, e: u32, ) -> Weight { + fn approve(_c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `657 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 24_000_000 picoseconds. - Weight::from_parts(27_069_498, 33054) - // Standard Error: 487 - .saturating_add(Weight::from_parts(191, 0).saturating_mul(c.into())) - // Standard Error: 24_658 - .saturating_add(Weight::from_parts(14_006_816, 0).saturating_mul(e.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(49_516_534, 33054) + // Standard Error: 26_699 + .saturating_add(Weight::from_parts(14_041_478, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -316,10 +321,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `790 + c * (1 ±0)` // Estimated: `33054` - // Minimum execution time: 29_000_000 picoseconds. - Weight::from_parts(36_252_020, 33054) - // Standard Error: 76 - .saturating_add(Weight::from_parts(615, 0).saturating_mul(c.into())) + // Minimum execution time: 30_000_000 picoseconds. + Weight::from_parts(35_686_901, 33054) + // Standard Error: 95 + .saturating_add(Weight::from_parts(1_060, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -329,14 +334,16 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn cancel(_c: u32, e: u32, ) -> Weight { + fn cancel(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `625 + c * (1 ±0) + e * (215 ±0)` // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 27_000_000 picoseconds. - Weight::from_parts(31_511_446, 33054) - // Standard Error: 22_515 - .saturating_add(Weight::from_parts(14_026_107, 0).saturating_mul(e.into())) + // Minimum execution time: 28_000_000 picoseconds. + Weight::from_parts(33_000_000, 33054) + // Standard Error: 229 + .saturating_add(Weight::from_parts(3_462, 0).saturating_mul(c.into())) + // Standard Error: 11_632 + .saturating_add(Weight::from_parts(13_752_458, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -352,7 +359,7 @@ impl WeightInfo for () { // Measured: `764` // Estimated: `17022` // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(25_000_000, 17022) + Weight::from_parts(26_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -366,9 +373,9 @@ impl WeightInfo for () { // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(33_197_740, 17022) - // Standard Error: 25_852 - .saturating_add(Weight::from_parts(13_770_988, 0).saturating_mul(p.into())) + Weight::from_parts(27_178_826, 17022) + // Standard Error: 23_441 + .saturating_add(Weight::from_parts(13_739_383, 0).saturating_mul(p.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(p.into()))) .saturating_add(RocksDbWeight::get().writes(1_u64)) @@ -386,7 +393,7 @@ impl WeightInfo for () { // Measured: `538` // Estimated: `17022` // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) + Weight::from_parts(26_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } diff --git a/pallets/reversible-transfers/Cargo.toml b/pallets/reversible-transfers/Cargo.toml index c79f934e..a6685469 100644 --- a/pallets/reversible-transfers/Cargo.toml +++ b/pallets/reversible-transfers/Cargo.toml @@ -21,6 +21,7 @@ pallet-assets.workspace = true pallet-assets-holder.workspace = true pallet-balances.workspace = true pallet-recovery.workspace = true +qp-high-security = { path = "../../primitives/high-security", default-features = false } qp-scheduler.workspace = true scale-info = { features = ["derive"], workspace = true } sp-arithmetic.workspace = true @@ -53,6 +54,7 @@ std = [ "pallet-scheduler/std", "pallet-timestamp/std", "pallet-utility/std", + "qp-high-security/std", "qp-scheduler/std", "scale-info/std", "sp-core/std", @@ -67,6 +69,7 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "pallet-assets/runtime-benchmarks", "pallet-balances/runtime-benchmarks", + "qp-high-security/runtime-benchmarks", "sp-runtime/runtime-benchmarks", ] try-runtime = [ diff --git a/pallets/reversible-transfers/src/benchmarking.rs b/pallets/reversible-transfers/src/benchmarking.rs index ae69f5b0..2824aaf7 100644 --- a/pallets/reversible-transfers/src/benchmarking.rs +++ b/pallets/reversible-transfers/src/benchmarking.rs @@ -13,6 +13,17 @@ use sp_runtime::{ const SEED: u32 = 0; +/// Helper for external benchmarks (e.g., `pallet-multisig`) to set up HS storage state. +/// Bypasses all validation - direct storage write only for benchmarking. +pub fn insert_hs_account_for_benchmark( + who: T::AccountId, + data: HighSecurityAccountData>, +) where + T: Config, +{ + HighSecurityAccounts::::insert(who, data); +} + // Helper to create a RuntimeCall (e.g., a balance transfer) // Adjust type parameters as needed for your actual Balance type if not u128 fn make_transfer_call( diff --git a/pallets/reversible-transfers/src/lib.rs b/pallets/reversible-transfers/src/lib.rs index 0122ab7f..45de0e47 100644 --- a/pallets/reversible-transfers/src/lib.rs +++ b/pallets/reversible-transfers/src/lib.rs @@ -20,7 +20,7 @@ pub use pallet::*; mod tests; #[cfg(feature = "runtime-benchmarks")] -mod benchmarking; +pub mod benchmarking; pub mod weights; pub use weights::WeightInfo; @@ -37,42 +37,6 @@ use qp_scheduler::{BlockNumberOrTimestamp, DispatchTime, ScheduleNamed}; use sp_arithmetic::Permill; use sp_runtime::traits::StaticLookup; -/// Trait for checking high-security status and whitelisting calls. -/// This can be used by other pallets (like multisig) or transaction extensions. -pub trait HighSecurityInspector { - /// Check if account is high-security - fn is_high_security(who: &AccountId) -> bool; - /// Check if call is whitelisted for high-security accounts - /// Note: This must be implemented at runtime level since it needs RuntimeCall - fn is_whitelisted(call: &RuntimeCall) -> bool; - /// Get guardian for high-security account - fn guardian(who: &AccountId) -> Option; - - /// Set account as high-security for benchmarking purposes only - /// This allows benchmarks to measure the HS code path without full runtime setup - #[cfg(feature = "runtime-benchmarks")] - fn set_high_security_for_benchmarking(who: &AccountId); -} - -/// Default implementation for HighSecurityInspector (no high-security) -/// This allows pallets to have optional high-security support by using `type HighSecurity = ();` -impl HighSecurityInspector for () { - fn is_high_security(_who: &AccountId) -> bool { - false - } - fn is_whitelisted(_call: &RuntimeCall) -> bool { - true // Allow everything if no high-security - } - fn guardian(_who: &AccountId) -> Option { - None - } - - #[cfg(feature = "runtime-benchmarks")] - fn set_high_security_for_benchmarking(_who: &AccountId) { - // No-op for default implementation - } -} - // Partial implementation for Pallet - runtime will complete it impl Pallet { /// Check if account is registered as high-security diff --git a/pallets/reversible-transfers/src/weights.rs b/pallets/reversible-transfers/src/weights.rs index b5c0f5e2..632d3504 100644 --- a/pallets/reversible-transfers/src/weights.rs +++ b/pallets/reversible-transfers/src/weights.rs @@ -19,9 +19,9 @@ //! Autogenerated weights for `pallet_reversible_transfers` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-01-26, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-01-30, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `arunachala.local`, CPU: `` +//! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` // Executed Command: @@ -33,8 +33,8 @@ // --extrinsic=* // --steps=50 // --repeat=20 -// --output=pallets/reversible-transfers/src/weights.rs -// --template=.maintain/frame-weight-template.hbs +// --template=./.maintain/frame-weight-template.hbs +// --output=./pallets/reversible-transfers/src/weights.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -63,10 +63,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ReversibleTransfers::InterceptorIndex` (`max_values`: None, `max_size`: Some(1073), added: 3548, mode: `MaxEncodedLen`) fn set_high_security() -> Weight { // Proof Size summary in bytes: - // Measured: `192` + // Measured: `152` // Estimated: `4538` - // Minimum execution time: 78_000_000 picoseconds. - Weight::from_parts(80_000_000, 4538) + // Minimum execution time: 41_000_000 picoseconds. + Weight::from_parts(43_000_000, 4538) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -92,10 +92,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ReversibleTransfers::PendingTransfers` (`max_values`: None, `max_size`: Some(291), added: 2766, mode: `MaxEncodedLen`) fn schedule_transfer() -> Weight { // Proof Size summary in bytes: - // Measured: `637` + // Measured: `597` // Estimated: `14183` - // Minimum execution time: 536_000_000 picoseconds. - Weight::from_parts(550_000_000, 14183) + // Minimum execution time: 285_000_000 picoseconds. + Weight::from_parts(307_000_000, 14183) .saturating_add(T::DbWeight::get().reads(9_u64)) .saturating_add(T::DbWeight::get().writes(8_u64)) } @@ -115,16 +115,16 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10718), added: 13193, mode: `MaxEncodedLen`) /// Storage: `Balances::Holds` (r:1 w:1) /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(85), added: 2560, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:2 w:2) + /// Storage: `System::Account` (r:1 w:1) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `2224` + // Measured: `1880` // Estimated: `14183` - // Minimum execution time: 342_000_000 picoseconds. - Weight::from_parts(349_000_000, 14183) - .saturating_add(T::DbWeight::get().reads(10_u64)) - .saturating_add(T::DbWeight::get().writes(9_u64)) + // Minimum execution time: 172_000_000 picoseconds. + Weight::from_parts(195_000_000, 14183) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(8_u64)) } /// Storage: `ReversibleTransfers::PendingTransfers` (r:1 w:1) /// Proof: `ReversibleTransfers::PendingTransfers` (`max_values`: None, `max_size`: Some(291), added: 2766, mode: `MaxEncodedLen`) @@ -146,8 +146,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1360` // Estimated: `3834` - // Minimum execution time: 276_000_000 picoseconds. - Weight::from_parts(290_000_000, 3834) + // Minimum execution time: 180_000_000 picoseconds. + Weight::from_parts(196_000_000, 3834) .saturating_add(T::DbWeight::get().reads(7_u64)) .saturating_add(T::DbWeight::get().writes(8_u64)) } @@ -163,8 +163,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `477` // Estimated: `3593` - // Minimum execution time: 103_000_000 picoseconds. - Weight::from_parts(106_000_000, 3593) + // Minimum execution time: 87_000_000 picoseconds. + Weight::from_parts(96_000_000, 3593) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -178,10 +178,10 @@ impl WeightInfo for () { /// Proof: `ReversibleTransfers::InterceptorIndex` (`max_values`: None, `max_size`: Some(1073), added: 3548, mode: `MaxEncodedLen`) fn set_high_security() -> Weight { // Proof Size summary in bytes: - // Measured: `192` + // Measured: `152` // Estimated: `4538` - // Minimum execution time: 78_000_000 picoseconds. - Weight::from_parts(80_000_000, 4538) + // Minimum execution time: 41_000_000 picoseconds. + Weight::from_parts(43_000_000, 4538) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -207,10 +207,10 @@ impl WeightInfo for () { /// Proof: `ReversibleTransfers::PendingTransfers` (`max_values`: None, `max_size`: Some(291), added: 2766, mode: `MaxEncodedLen`) fn schedule_transfer() -> Weight { // Proof Size summary in bytes: - // Measured: `637` + // Measured: `597` // Estimated: `14183` - // Minimum execution time: 536_000_000 picoseconds. - Weight::from_parts(550_000_000, 14183) + // Minimum execution time: 285_000_000 picoseconds. + Weight::from_parts(307_000_000, 14183) .saturating_add(RocksDbWeight::get().reads(9_u64)) .saturating_add(RocksDbWeight::get().writes(8_u64)) } @@ -230,16 +230,16 @@ impl WeightInfo for () { /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10718), added: 13193, mode: `MaxEncodedLen`) /// Storage: `Balances::Holds` (r:1 w:1) /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(85), added: 2560, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:2 w:2) + /// Storage: `System::Account` (r:1 w:1) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `2224` + // Measured: `1880` // Estimated: `14183` - // Minimum execution time: 342_000_000 picoseconds. - Weight::from_parts(349_000_000, 14183) - .saturating_add(RocksDbWeight::get().reads(10_u64)) - .saturating_add(RocksDbWeight::get().writes(9_u64)) + // Minimum execution time: 172_000_000 picoseconds. + Weight::from_parts(195_000_000, 14183) + .saturating_add(RocksDbWeight::get().reads(9_u64)) + .saturating_add(RocksDbWeight::get().writes(8_u64)) } /// Storage: `ReversibleTransfers::PendingTransfers` (r:1 w:1) /// Proof: `ReversibleTransfers::PendingTransfers` (`max_values`: None, `max_size`: Some(291), added: 2766, mode: `MaxEncodedLen`) @@ -261,8 +261,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1360` // Estimated: `3834` - // Minimum execution time: 276_000_000 picoseconds. - Weight::from_parts(290_000_000, 3834) + // Minimum execution time: 180_000_000 picoseconds. + Weight::from_parts(196_000_000, 3834) .saturating_add(RocksDbWeight::get().reads(7_u64)) .saturating_add(RocksDbWeight::get().writes(8_u64)) } @@ -278,8 +278,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `477` // Estimated: `3593` - // Minimum execution time: 103_000_000 picoseconds. - Weight::from_parts(106_000_000, 3593) + // Minimum execution time: 87_000_000 picoseconds. + Weight::from_parts(96_000_000, 3593) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } diff --git a/primitives/high-security/Cargo.toml b/primitives/high-security/Cargo.toml new file mode 100644 index 00000000..b0b60729 --- /dev/null +++ b/primitives/high-security/Cargo.toml @@ -0,0 +1,22 @@ +[package] +authors.workspace = true +description = "High-Security account primitives for Quantus blockchain" +edition.workspace = true +homepage.workspace = true +license = "Apache-2.0" +name = "qp-high-security" +publish = false +repository.workspace = true +version = "0.1.0" + +[dependencies] +codec = { workspace = true } +scale-info = { workspace = true } + +[features] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", +] +runtime-benchmarks = [] diff --git a/primitives/high-security/src/lib.rs b/primitives/high-security/src/lib.rs new file mode 100644 index 00000000..d923ec67 --- /dev/null +++ b/primitives/high-security/src/lib.rs @@ -0,0 +1,156 @@ +//! High-Security Account Primitives +//! +//! This crate provides the core trait for High-Security account inspection and validation +//! in the Quantus blockchain. High-Security accounts are designed for institutional users +//! and high-value accounts that require additional security controls: +//! +//! - **Call whitelisting**: Only approved operations can be executed +//! - **Guardian/interceptor role**: Designated account can cancel malicious transactions +//! - **Delayed execution**: Time window for intervention before irreversible actions +//! +//! ## Architecture +//! +//! The `HighSecurityInspector` trait is implemented at the runtime level and consumed by: +//! - `pallet-multisig`: Validates calls in High-Security multisigs +//! - `pallet-reversible-transfers`: Provides the storage and core HS account management +//! - Transaction extensions: Validates calls for High-Security EOAs +//! +//! This primitives crate breaks the circular dependency between pallets by providing +//! a shared abstraction that all consumers can depend on. + +#![cfg_attr(not(feature = "std"), no_std)] + +/// High-Security account inspector trait +/// +/// Provides methods to check if an account is designated as High-Security, +/// validate whitelisted calls, and retrieve guardian information. +/// +/// # Type Parameters +/// +/// - `AccountId`: The account identifier type +/// - `RuntimeCall`: The runtime-level call enum (required for whitelist validation) +/// +/// # Implementation Notes +/// +/// This trait is typically implemented at the runtime level in a configuration struct +/// that bridges multiple pallets. The runtime implementation delegates to the actual +/// storage pallet (e.g., `pallet-reversible-transfers`) for account status checks +/// and defines the runtime-specific whitelist logic. +/// +/// # Example +/// +/// ```ignore +/// // In runtime/src/configs/mod.rs +/// pub struct HighSecurityConfig; +/// +/// impl qp_high_security::HighSecurityInspector +/// for HighSecurityConfig +/// { +/// fn is_high_security(who: &AccountId) -> bool { +/// pallet_reversible_transfers::Pallet::::is_high_security_account(who) +/// } +/// +/// fn is_whitelisted(call: &RuntimeCall) -> bool { +/// matches!( +/// call, +/// RuntimeCall::ReversibleTransfers( +/// pallet_reversible_transfers::Call::schedule_transfer { .. } +/// ) +/// ) +/// } +/// +/// fn guardian(who: &AccountId) -> Option { +/// pallet_reversible_transfers::Pallet::::get_guardian(who) +/// } +/// } +/// ``` +pub trait HighSecurityInspector { + /// Check if an account is designated as High-Security + /// + /// High-Security accounts are restricted to executing only whitelisted calls + /// and have a guardian that can intercept malicious transactions. + /// + /// # Parameters + /// + /// - `who`: The account to check + /// + /// # Returns + /// + /// `true` if the account is High-Security, `false` otherwise + fn is_high_security(who: &AccountId) -> bool; + + /// Check if a runtime call is whitelisted for High-Security accounts + /// + /// The whitelist is typically defined at the runtime level and includes only + /// operations that are reversible or delayed (e.g., scheduled transfers). + /// + /// # Parameters + /// + /// - `call`: The runtime call to validate + /// + /// # Returns + /// + /// `true` if the call is whitelisted, `false` otherwise + /// + /// # Implementation Notes + /// + /// The runtime-level implementation typically uses pattern matching on `RuntimeCall`: + /// + /// ```ignore + /// matches!( + /// call, + /// RuntimeCall::ReversibleTransfers( + /// pallet_reversible_transfers::Call::schedule_transfer { .. } + /// ) | RuntimeCall::ReversibleTransfers( + /// pallet_reversible_transfers::Call::cancel { .. } + /// ) + /// ) + /// ``` + fn is_whitelisted(call: &RuntimeCall) -> bool; + + /// Get the guardian/interceptor account for a High-Security account + /// + /// The guardian has special privileges to cancel pending transactions + /// initiated by the High-Security account. + /// + /// # Parameters + /// + /// - `who`: The High-Security account + /// + /// # Returns + /// + /// `Some(guardian_account)` if the account has a guardian, `None` otherwise + fn guardian(who: &AccountId) -> Option; + + // NOTE: No benchmarking-specific methods in the trait! + // Production API should not be polluted by test/benchmark requirements. + // Use pallet-specific helpers instead (e.g., + // pallet_reversible_transfers::Pallet::add_high_security_benchmark_account) +} + +/// Default implementation for `HighSecurityInspector` +/// +/// This implementation disables all High-Security checks, allowing any call to execute. +/// It's useful for: +/// - Test environments that don't need HS enforcement +/// - Pallets that want optional HS support via `type HighSecurity = ();` +/// - Gradual feature rollout +/// +/// # Behavior +/// +/// - `is_high_security()`: Always returns `false` +/// - `is_whitelisted()`: Always returns `true` (allow everything) +/// - `guardian()`: Always returns `None` +impl HighSecurityInspector for () { + fn is_high_security(_who: &AccountId) -> bool { + false + } + + fn is_whitelisted(_call: &RuntimeCall) -> bool { + true // Allow everything if no High-Security enforcement + } + + fn guardian(_who: &AccountId) -> Option { + None + } +} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index fe292af7..cda93027 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -49,6 +49,7 @@ pallet-utility.workspace = true primitive-types.workspace = true qp-dilithium-crypto.workspace = true qp-header = { workspace = true, features = ["serde"] } +qp-high-security = { path = "../primitives/high-security", default-features = false } qp-poseidon = { workspace = true, features = ["serde"] } qp-scheduler.workspace = true scale-info = { features = ["derive", "serde"], workspace = true } @@ -114,6 +115,7 @@ std = [ "qp-dilithium-crypto/full_crypto", "qp-dilithium-crypto/std", "qp-header/std", + "qp-high-security/std", "qp-poseidon/std", "qp-scheduler/std", "scale-info/std", diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 04eba988..acb15de3 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -592,9 +592,7 @@ parameter_types! { /// - `cancel`: Cancel pending delayed transfer pub struct HighSecurityConfig; -impl pallet_reversible_transfers::HighSecurityInspector - for HighSecurityConfig -{ +impl qp_high_security::HighSecurityInspector for HighSecurityConfig { fn is_high_security(who: &AccountId) -> bool { // Delegate to reversible-transfers pallet pallet_reversible_transfers::Pallet::::is_high_security_account(who) @@ -604,8 +602,7 @@ impl pallet_reversible_transfers::HighSecurityInspector // Runtime-level whitelist: only reversible-transfers operations allowed #[cfg(feature = "runtime-benchmarks")] { - // For benchmarking: allow system::remark to measure decode overhead - // without side effects from actual transfer operations + // For benchmarking: allow system::remark to measure O(c) decode overhead if matches!(call, RuntimeCall::System(frame_system::Call::remark { .. })) { return true; } @@ -625,19 +622,6 @@ impl pallet_reversible_transfers::HighSecurityInspector // Delegate to reversible-transfers pallet pallet_reversible_transfers::Pallet::::get_guardian(who) } - - #[cfg(feature = "runtime-benchmarks")] - fn set_high_security_for_benchmarking(who: &AccountId) { - use pallet_reversible_transfers::{HighSecurityAccountData, HighSecurityAccounts}; - use qp_scheduler::BlockNumberOrTimestamp; - - // Insert dummy HS data for benchmarking - let hs_data = HighSecurityAccountData { - interceptor: who.clone(), - delay: BlockNumberOrTimestamp::BlockNumber(100u32), - }; - HighSecurityAccounts::::insert(who, hs_data); - } } impl pallet_multisig::Config for Runtime { diff --git a/runtime/src/transaction_extensions.rs b/runtime/src/transaction_extensions.rs index 0bbfc2b3..b26f6a81 100644 --- a/runtime/src/transaction_extensions.rs +++ b/runtime/src/transaction_extensions.rs @@ -5,7 +5,7 @@ use core::marker::PhantomData; use frame_support::pallet_prelude::{InvalidTransaction, ValidTransaction}; use frame_system::ensure_signed; -use pallet_reversible_transfers::HighSecurityInspector; +use qp_high_security::HighSecurityInspector; use scale_info::TypeInfo; use sp_core::Get; use sp_runtime::{traits::TransactionExtension, Weight}; From 282b3fda789d4a3b7007194339f8a63d0ebdf007 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Fri, 30 Jan 2026 11:51:30 +0800 Subject: [PATCH 05/15] fix: Taplo --- primitives/high-security/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/primitives/high-security/Cargo.toml b/primitives/high-security/Cargo.toml index b0b60729..d213ef10 100644 --- a/primitives/high-security/Cargo.toml +++ b/primitives/high-security/Cargo.toml @@ -15,8 +15,8 @@ scale-info = { workspace = true } [features] default = ["std"] +runtime-benchmarks = [] std = [ "codec/std", "scale-info/std", ] -runtime-benchmarks = [] From db408efd7acbd702c489f20cf6af5c2f3b150088 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 3 Feb 2026 11:25:08 +0800 Subject: [PATCH 06/15] fix: Sell order tracking --- pallets/multisig/src/benchmarking.rs | 76 ++--- pallets/multisig/src/lib.rs | 447 ++++++++++++--------------- pallets/multisig/src/tests.rs | 282 ++++++++++------- pallets/multisig/src/weights.rs | 68 ++-- 4 files changed, 422 insertions(+), 451 deletions(-) diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 2ed1ff71..e9d98d1a 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -32,26 +32,32 @@ mod benchmarks { use codec::Encode; #[benchmark] - fn create_multisig() -> Result<(), BenchmarkError> { + fn create_multisig( + s: Linear<2, { T::MaxSigners::get() }>, // number of signers + ) -> Result<(), BenchmarkError> { let caller: T::AccountId = whitelisted_caller(); // Fund the caller with enough balance for deposit fund_account::(&caller, BalanceOf2::::from(10000u128)); // Create signers (including caller) - let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); - let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); - let signers = vec![caller.clone(), signer1, signer2]; + let mut signers = vec![caller.clone()]; + for i in 0..s.saturating_sub(1) { + let signer: T::AccountId = benchmark_account("signer", i, SEED); + signers.push(signer); + } let threshold = 2u32; + let nonce = 0u64; #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), signers.clone(), threshold); + _(RawOrigin::Signed(caller.clone()), signers.clone(), threshold, nonce); // Verify the multisig was created // Note: signers are sorted internally, so we must sort for address derivation let mut sorted_signers = signers.clone(); sorted_signers.sort(); - let multisig_address = Multisig::::derive_multisig_address(&sorted_signers, 0); + let multisig_address = + Multisig::::derive_multisig_address(&sorted_signers, threshold, nonce); assert!(Multisigs::::contains_key(multisig_address)); Ok(()) @@ -404,18 +410,13 @@ mod benchmarks { } #[benchmark] - fn cancel( - c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, - e: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, // expired proposals to cleanup - ) -> Result<(), BenchmarkError> { + fn cancel() -> Result<(), BenchmarkError> { // Setup: Create multisig and proposal directly in storage let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); - fund_account::(&signer1, BalanceOf2::::from(100000u128)); - fund_account::(&signer2, BalanceOf2::::from(100000u128)); let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; let threshold = 2u32; @@ -424,51 +425,24 @@ mod benchmarks { signers.sort(); // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { signers: bounded_signers, threshold, - nonce: 0, - proposal_nonce: e + 1, // We'll insert e expired proposals + 1 active - creator: caller.clone(), + proposal_nonce: 1, deposit: T::MultisigDeposit::get(), - last_activity: frame_system::Pallet::::block_number(), - active_proposals: e + 1, + active_proposals: 1, proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); - // Insert e expired proposals (worst case for auto-cleanup) - let expired_block = 10u32.into(); - for i in 0..e { - let system_call = frame_system::Call::::remark { remark: vec![i as u8; 10] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); - let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); - let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - let proposal_data = ProposalDataOf:: { - proposer: caller.clone(), - call: bounded_call, - expiry: expired_block, - approvals: bounded_approvals, - deposit: 10u32.into(), - status: ProposalStatus::Active, - }; - Proposals::::insert(&multisig_address, i, proposal_data); - } - - // Move past expiry so proposals are expired - frame_system::Pallet::::set_block_number(100u32.into()); - // Directly insert active proposal into storage - // Create a remark call where the remark itself is c bytes - let system_call = frame_system::Call::::remark { remark: vec![1u8; c as usize] }; + let system_call = frame_system::Call::::remark { remark: vec![1u8; 10] }; let call = ::RuntimeCall::from(system_call); let encoded_call = call.encode(); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); - let bounded_call: BoundedCallOf = encoded_call.clone().try_into().unwrap(); + let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); let proposal_data = ProposalDataOf:: { @@ -476,17 +450,20 @@ mod benchmarks { call: bounded_call, expiry, approvals: bounded_approvals, - deposit: 10u32.into(), + deposit: T::ProposalDeposit::get(), status: ProposalStatus::Active, }; - let proposal_id = e; // Active proposal after expired ones + let proposal_id = 0; Proposals::::insert(&multisig_address, proposal_id, proposal_data); + // Reserve deposit for proposer + T::Currency::reserve(&caller, T::ProposalDeposit::get()).unwrap(); + #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_id); - // Verify proposal was removed from storage (auto-deleted after cancellation) + // Verify proposal was removed from storage assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); Ok(()) @@ -579,16 +556,13 @@ mod benchmarks { signers.sort(); // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { signers: bounded_signers, threshold, - nonce: 0, proposal_nonce: p, // We'll insert p proposals with ids 0..p-1 - creator: caller.clone(), deposit: T::MultisigDeposit::get(), - last_activity: 1u32.into(), active_proposals: p, proposals_per_signer: BoundedBTreeMap::new(), }; diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 061e7e0f..240e4ed3 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -40,46 +40,31 @@ use sp_runtime::RuntimeDebug; /// Multisig account data #[derive(Encode, Decode, MaxEncodedLen, Clone, TypeInfo, RuntimeDebug, PartialEq, Eq)] -pub struct MultisigData -{ +pub struct MultisigData { /// List of signers who can approve transactions pub signers: BoundedSigners, /// Number of approvals required to execute a transaction pub threshold: u32, - /// Global unique identifier for this multisig (for address derivation) - pub nonce: u64, - /// Proposal counter for unique proposal hashes + /// Proposal counter for unique proposal IDs pub proposal_nonce: u32, - /// Account that created this multisig - pub creator: AccountId, - /// Deposit reserved by the creator + /// Deposit reserved by the creator (returned on dissolve) pub deposit: Balance, - /// Last block when this multisig was used - pub last_activity: BlockNumber, - /// Number of currently active (non-executed/non-cancelled) proposals + /// Number of active proposals (for global limit checking) pub active_proposals: u32, - /// Counter of proposals in storage per signer (for filibuster protection) + /// Per-signer proposal count (for filibuster protection) + /// Maps AccountId -> number of active proposals pub proposals_per_signer: BoundedProposalsPerSigner, } -impl< - BlockNumber: Default, - AccountId: Default, - BoundedSigners: Default, - Balance: Default, - BoundedProposalsPerSigner: Default, - > Default - for MultisigData +impl Default + for MultisigData { fn default() -> Self { Self { signers: Default::default(), threshold: 1, - nonce: 0, proposal_nonce: 0, - creator: Default::default(), deposit: Default::default(), - last_activity: Default::default(), active_proposals: 0, proposals_per_signer: Default::default(), } @@ -225,18 +210,13 @@ pub mod pallet { /// Type alias for bounded call data pub type BoundedCallOf = BoundedVec::MaxCallSize>; - /// Type alias for bounded proposals per signer map + /// Type alias for per-signer proposal counts pub type BoundedProposalsPerSignerOf = BoundedBTreeMap<::AccountId, u32, ::MaxSigners>; /// Type alias for MultisigData with proper bounds - pub type MultisigDataOf = MultisigData< - BlockNumberFor, - ::AccountId, - BoundedSignersOf, - BalanceOf, - BoundedProposalsPerSignerOf, - >; + pub type MultisigDataOf = + MultisigData, BalanceOf, BoundedProposalsPerSignerOf>; /// Type alias for ProposalData with proper bounds pub type ProposalDataOf = ProposalData< @@ -247,11 +227,7 @@ pub mod pallet { BoundedApprovalsOf, >; - /// Global nonce for generating unique multisig addresses - #[pallet::storage] - pub type GlobalNonce = StorageValue<_, u64, ValueQuery>; - - /// Multisigs stored by their generated address + /// Multisigs stored by their deterministic address #[pallet::storage] #[pallet::getter(fn multisigs)] pub type Multisigs = @@ -270,6 +246,12 @@ pub mod pallet { OptionQuery, >; + /// Dissolve approvals: tracks which signers approved dissolving the multisig + /// Maps multisig_address -> Vec + #[pallet::storage] + pub type DissolveApprovals = + StorageMap<_, Blake2_128Concat, T::AccountId, BoundedApprovalsOf, OptionQuery>; + #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { @@ -322,11 +304,17 @@ pub mod pallet { proposals_removed: u32, multisig_removed: bool, }, - /// A multisig account was dissolved and deposit returned + /// A signer approved dissolving the multisig + DissolveApproved { + multisig_address: T::AccountId, + approver: T::AccountId, + approvals_count: u32, + }, + /// A multisig account was dissolved (threshold reached) MultisigDissolved { multisig_address: T::AccountId, - caller: T::AccountId, deposit_returned: BalanceOf, + approvers: Vec, }, } @@ -397,11 +385,12 @@ pub mod pallet { /// The multisig address is derived from a hash of all signers + global nonce. /// The creator must pay a non-refundable fee (burned). #[pallet::call_index(0)] - #[pallet::weight(::WeightInfo::create_multisig())] + #[pallet::weight(::WeightInfo::create_multisig(signers.len() as u32))] pub fn create_multisig( origin: OriginFor, signers: Vec, threshold: u32, + nonce: u64, ) -> DispatchResult { let creator = ensure_signed(origin)?; @@ -411,8 +400,7 @@ pub mod pallet { ensure!(threshold <= signers.len() as u32, Error::::ThresholdTooHigh); ensure!(signers.len() <= T::MaxSigners::get() as usize, Error::::TooManySigners); - // Sort signers for deterministic address generation - // (order shouldn't matter - nonce provides uniqueness) + // Sort signers for duplicate check and storage let mut sorted_signers = signers.clone(); sorted_signers.sort(); @@ -421,12 +409,10 @@ pub mod pallet { ensure!(sorted_signers[i] != sorted_signers[i - 1], Error::::DuplicateSigner); } - // Get and increment global nonce - let nonce = GlobalNonce::::get(); - GlobalNonce::::put(nonce.saturating_add(1)); - - // Generate multisig address from hash of (sorted_signers, nonce) - let multisig_address = Self::derive_multisig_address(&sorted_signers, nonce); + // Generate deterministic multisig address + // Note: derive_multisig_address() will sort internally, but we already have sorted + // for duplicate check, so we pass sorted to avoid double sorting + let multisig_address = Self::derive_multisig_address(&sorted_signers, threshold, nonce); // Ensure multisig doesn't already exist ensure!( @@ -452,22 +438,16 @@ pub mod pallet { let bounded_signers: BoundedSignersOf = sorted_signers.try_into().map_err(|_| Error::::TooManySigners)?; - // Get current block for last_activity - let current_block = frame_system::Pallet::::block_number(); - // Store multisig data Multisigs::::insert( &multisig_address, MultisigDataOf:: { signers: bounded_signers.clone(), threshold, - nonce, proposal_nonce: 0, - creator: creator.clone(), deposit, - last_activity: current_block, active_proposals: 0, - proposals_per_signer: Default::default(), + proposals_per_signer: BoundedProposalsPerSignerOf::::default(), }, ); @@ -494,9 +474,8 @@ pub mod pallet { /// - A deposit (refundable - returned immediately on execution/cancellation) /// - A fee (non-refundable, burned immediately) /// - /// **Auto-cleanup:** Before creating a new proposal, ALL expired proposals are - /// automatically removed and deposits returned to original proposers. This is the primary - /// cleanup mechanism. + /// **Auto-cleanup:** Before creating a new proposal, ALL proposer's expired + /// proposals are automatically removed. This is the primary cleanup mechanism. /// /// **For threshold=1:** If the multisig threshold is 1, the proposal executes immediately. /// @@ -538,9 +517,9 @@ pub mod pallet { ); } - // Auto-cleanup expired proposals before creating new one + // Auto-cleanup ALL proposer's expired proposals before creating new one // This is the primary cleanup mechanism for active multisigs - let iterated_count = Self::auto_cleanup_expired_proposals(&multisig_address, &proposer); + let _cleaned = Self::cleanup_proposer_expired(&multisig_address, &proposer, &proposer); // Reload multisig data after potential cleanup let multisig_data = @@ -561,16 +540,15 @@ pub mod pallet { ); // Check per-signer proposal limit (filibuster protection) - // Each signer can have at most (MaxTotal / NumSigners) proposals in storage - // This prevents a single signer from monopolizing the proposal queue - // Use saturating_div to handle edge cases (division by 0, etc.) and ensure at least 1 - let max_per_signer = T::MaxTotalProposalsInStorage::get() - .checked_div(signers_count) - .unwrap_or(1) // If division fails (shouldn't happen), allow at least 1 - .max(1); // Ensure minimum of 1 proposal per signer - let proposer_count = + // Each signer can have max (TotalLimit / SignersCount) proposals + let max_proposals_per_signer = + T::MaxTotalProposalsInStorage::get().saturating_div(signers_count); + let proposer_current_count = multisig_data.proposals_per_signer.get(&proposer).copied().unwrap_or(0); - ensure!(proposer_count < max_per_signer, Error::::TooManyProposalsPerSigner); + ensure!( + proposer_current_count < max_proposals_per_signer, + Error::::TooManyProposalsPerSigner + ); // Check call size ensure!(call.len() as u32 <= T::MaxCallSize::get(), Error::::CallTooLarge); @@ -607,13 +585,6 @@ pub mod pallet { T::Currency::reserve(&proposer, deposit) .map_err(|_| Error::::InsufficientBalance)?; - // Update multisig last_activity - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { - multisig.last_activity = current_block; - } - }); - // Convert to bounded vec (call_size already computed and validated above) let bounded_call: BoundedCallOf = call.try_into().map_err(|_| Error::::CallTooLarge)?; @@ -645,17 +616,14 @@ pub mod pallet { // Store proposal with nonce as key (simple and efficient) Proposals::::insert(&multisig_address, proposal_id, proposal); - // Increment active proposals counter and per-signer counter - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { - multisig.active_proposals = multisig.active_proposals.saturating_add(1); - - // Update per-signer counter for filibuster protection - let current_count = - multisig.proposals_per_signer.get(&proposer).copied().unwrap_or(0); - let _ = multisig + // Increment proposal counters + Multisigs::::mutate(&multisig_address, |maybe_data| { + if let Some(ref mut data) = maybe_data { + data.active_proposals = data.active_proposals.saturating_add(1); + let count = data.proposals_per_signer.get(&proposer).copied().unwrap_or(0); + let _ = data .proposals_per_signer - .try_insert(proposer.clone(), current_count.saturating_add(1)); + .try_insert(proposer.clone(), count.saturating_add(1)); } }); @@ -676,13 +644,14 @@ pub mod pallet { Self::do_execute(multisig_address, proposal_id, proposal)?; } - // Calculate actual weight and refund if not high-security + // Calculate actual weight based on call size + // Note: cleanup cost is included in base weight (worst-case all proposals) let actual_weight = if is_high_security { // Used high-security path (decode + whitelist check) - ::WeightInfo::propose_high_security(call_size, iterated_count) + ::WeightInfo::propose_high_security(call_size, 0) } else { // Used normal path (no decode overhead) - ::WeightInfo::propose(call_size, iterated_count) + ::WeightInfo::propose(call_size, 0) }; Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) @@ -693,19 +662,13 @@ pub mod pallet { /// If this approval brings the total approvals to or above the threshold, /// the transaction will be automatically executed. /// - /// **Auto-cleanup:** Before processing the approval, ALL expired proposals are - /// automatically removed and deposits returned to original proposers. - /// /// Parameters: /// - `multisig_address`: The multisig account /// - `proposal_id`: ID (nonce) of the proposal to approve /// - /// Weight: Charges for MAX call size and MAX expired proposals, refunds based on actual + /// Weight: Charges for MAX call size, refunds based on actual #[pallet::call_index(2)] - #[pallet::weight(::WeightInfo::approve( - T::MaxCallSize::get(), - T::MaxTotalProposalsInStorage::get() - ))] + #[pallet::weight(::WeightInfo::approve(T::MaxCallSize::get(), 0))] #[allow(clippy::useless_conversion)] pub fn approve( origin: OriginFor, @@ -717,19 +680,13 @@ pub mod pallet { // Check if approver is a signer let multisig_data = Self::ensure_is_signer(&multisig_address, &approver)?; - // Auto-cleanup expired proposals on any multisig activity - // Returns count of proposals in storage (which determines iteration cost) - let iterated_count = Self::auto_cleanup_expired_proposals(&multisig_address, &approver); - // Get proposal let mut proposal = Proposals::::get(&multisig_address, proposal_id) .ok_or(Error::::ProposalNotFound)?; - // Calculate actual weight based on real call size and actual storage size - // We charge for worst-case (e=Max), but refund based on actual storage size + // Calculate actual weight based on real call size let actual_call_size = proposal.call.len() as u32; - let actual_weight = - ::WeightInfo::approve(actual_call_size, iterated_count); + let actual_weight = ::WeightInfo::approve(actual_call_size, 0); // Check if not expired let current_block = frame_system::Pallet::::block_number(); @@ -761,13 +718,6 @@ pub mod pallet { } else { // Not ready yet, just save the proposal Proposals::::insert(&multisig_address, proposal_id, proposal); - - // Update multisig last_activity - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { - multisig.last_activity = frame_system::Pallet::::block_number(); - } - }); } // Return actual weight (refund overpayment) @@ -776,19 +726,11 @@ pub mod pallet { /// Cancel a proposed transaction (only by proposer) /// - /// **Auto-cleanup:** Before processing the cancellation, ALL expired proposals are - /// automatically removed and deposits returned to original proposers. - /// /// Parameters: /// - `multisig_address`: The multisig account /// - `proposal_id`: ID (nonce) of the proposal to cancel - /// - /// Weight: Charges for MAX call size and MAX expired proposals, refunds based on actual #[pallet::call_index(3)] - #[pallet::weight(::WeightInfo::cancel( - T::MaxCallSize::get(), - T::MaxTotalProposalsInStorage::get() - ))] + #[pallet::weight(::WeightInfo::cancel())] #[allow(clippy::useless_conversion)] pub fn cancel( origin: OriginFor, @@ -797,20 +739,10 @@ pub mod pallet { ) -> DispatchResultWithPostInfo { let canceller = ensure_signed(origin)?; - // Auto-cleanup expired proposals on any multisig activity - // Returns count of proposals in storage (which determines iteration cost) - let iterated_count = - Self::auto_cleanup_expired_proposals(&multisig_address, &canceller); - // Get proposal let proposal = Proposals::::get(&multisig_address, proposal_id) .ok_or(Error::::ProposalNotFound)?; - // Calculate actual weight based on real call size and actual storage size - // We charge for worst-case (e=Max), but refund based on actual storage size - let actual_call_size = proposal.call.len() as u32; - let actual_weight = ::WeightInfo::cancel(actual_call_size, iterated_count); - // Check if caller is the proposer ensure!(canceller == proposal.proposer, Error::::NotProposer); @@ -832,7 +764,7 @@ pub mod pallet { proposal_id, }); - // Return actual weight (refund overpayment) + let actual_weight = ::WeightInfo::cancel(); Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } @@ -899,96 +831,64 @@ pub mod pallet { /// Returns all proposal deposits to the proposer in a single transaction. #[pallet::call_index(5)] #[pallet::weight(::WeightInfo::claim_deposits( - T::MaxTotalProposalsInStorage::get() - ))] + T::MaxTotalProposalsInStorage::get() +))] + #[allow(clippy::useless_conversion)] pub fn claim_deposits( origin: OriginFor, multisig_address: T::AccountId, - ) -> DispatchResult { + ) -> DispatchResultWithPostInfo { let caller = ensure_signed(origin)?; - let current_block = frame_system::Pallet::::block_number(); - - let mut total_returned = BalanceOf::::zero(); - let mut removed_count = 0u32; - - // Iterate through all proposals for this multisig - // Only Active+Expired proposals exist (Executed/Cancelled are auto-removed) - let proposals_to_remove: Vec<(u32, ProposalDataOf)> = - Proposals::::iter_prefix(&multisig_address) - .filter(|(_, proposal)| { - // Only proposals where caller is proposer - if proposal.proposer != caller { - return false; - } - - // Only Active proposals can exist (Executed/Cancelled auto-removed) - // Must be expired to remove - proposal.status == ProposalStatus::Active && current_block > proposal.expiry - }) - .collect(); + // Cleanup ALL caller's expired proposals + let cleaned = Self::cleanup_proposer_expired(&multisig_address, &caller, &caller); - // Remove proposals and return deposits - for (id, proposal) in proposals_to_remove { - total_returned = total_returned.saturating_add(proposal.deposit); - removed_count = removed_count.saturating_add(1); - - // Remove from storage and return deposit - Self::remove_proposal_and_return_deposit( - &multisig_address, - id, - &proposal.proposer, - proposal.deposit, - ); - - // Emit event for each removed proposal - Self::deposit_event(Event::ProposalRemoved { - multisig_address: multisig_address.clone(), - proposal_id: id, - proposer: caller.clone(), - removed_by: caller.clone(), - }); - } + let deposit_per_proposal = T::ProposalDeposit::get(); + let total_returned = deposit_per_proposal.saturating_mul(cleaned.into()); // Emit summary event Self::deposit_event(Event::DepositsClaimed { multisig_address: multisig_address.clone(), claimer: caller, total_returned, - proposals_removed: removed_count, - multisig_removed: false, // Multisig is never auto-removed now + proposals_removed: cleaned, + multisig_removed: false, }); - Ok(()) + // Return actual weight based on number of proposals cleaned + let actual_weight = ::WeightInfo::claim_deposits(cleaned); + Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } - /// Dissolve (remove) a multisig and recover the creation deposit. + /// Approve dissolving a multisig account + /// + /// Signers call this to approve dissolving the multisig. + /// When threshold is reached, the multisig is automatically dissolved. /// /// Requirements: - /// - No proposals exist (active, executed, or cancelled) - must be fully cleaned up. - /// - Multisig account balance must be zero. - /// - Can be called by the creator OR any signer. + /// - Caller must be a signer + /// - No proposals exist (active, executed, or cancelled) - must be fully cleaned up + /// - Multisig account balance must be zero /// - /// The deposit is ALWAYS returned to the original `creator` stored in `MultisigData`. + /// When threshold is reached: + /// - Deposit is burned (stays locked forever) + /// - Multisig storage is removed #[pallet::call_index(6)] #[pallet::weight(::WeightInfo::dissolve_multisig())] - pub fn dissolve_multisig( + pub fn approve_dissolve( origin: OriginFor, multisig_address: T::AccountId, ) -> DispatchResult { - let caller = ensure_signed(origin)?; + let approver = ensure_signed(origin)?; // 1. Get multisig data let multisig_data = Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; - // 2. Check permissions: Creator OR Any Signer - let is_signer = multisig_data.signers.contains(&caller); - let is_creator = multisig_data.creator == caller; - ensure!(is_signer || is_creator, Error::::NotASigner); + // 2. Check permissions: Must be a signer + ensure!(multisig_data.signers.contains(&approver), Error::::NotASigner); // 3. Check if account is clean (no proposals at all) - // iter_prefix is efficient enough here as we just need to check if ANY exist if Proposals::::iter_prefix(&multisig_address).next().is_some() { return Err(Error::::ProposalsExist.into()); } @@ -997,27 +897,65 @@ pub mod pallet { let balance = T::Currency::total_balance(&multisig_address); ensure!(balance.is_zero(), Error::::MultisigAccountNotZero); - // 5. Return deposit to creator - T::Currency::unreserve(&multisig_data.creator, multisig_data.deposit); + // 5. Get or create approval list + let mut approvals = DissolveApprovals::::get(&multisig_address).unwrap_or_default(); - // 6. Remove multisig from storage - Multisigs::::remove(&multisig_address); + // 6. Check if already approved + ensure!(!approvals.contains(&approver), Error::::AlreadyApproved); - // 7. Emit event - Self::deposit_event(Event::MultisigDissolved { - multisig_address, - caller, - deposit_returned: multisig_data.deposit, + // 7. Add approval + approvals.try_push(approver.clone()).map_err(|_| Error::::TooManySigners)?; + + let approvals_count = approvals.len() as u32; + + // 8. Emit approval event + Self::deposit_event(Event::DissolveApproved { + multisig_address: multisig_address.clone(), + approver, + approvals_count, }); + // 9. Check if threshold reached + if approvals_count >= multisig_data.threshold { + // Threshold reached - dissolve multisig + let deposit = multisig_data.deposit; + + // Remove multisig from storage (deposit stays locked/burned) + Multisigs::::remove(&multisig_address); + DissolveApprovals::::remove(&multisig_address); + + // Emit dissolved event + Self::deposit_event(Event::MultisigDissolved { + multisig_address, + deposit_returned: deposit, + approvers: approvals.to_vec(), + }); + } else { + // Not ready yet, save approvals + DissolveApprovals::::insert(&multisig_address, approvals); + } + Ok(()) } } impl Pallet { - /// Derive a multisig address from signers and nonce - pub fn derive_multisig_address(signers: &[T::AccountId], nonce: u64) -> T::AccountId { - // Create a unique identifier from pallet id + signers + nonce. + /// Derive a deterministic multisig address from signers, threshold, and nonce + /// + /// The address is computed as: hash(pallet_id || sorted_signers || threshold || nonce) + /// Signers are automatically sorted internally for deterministic results. + /// This allows users to pre-compute the address before creating the multisig. + pub fn derive_multisig_address( + signers: &[T::AccountId], + threshold: u32, + nonce: u64, + ) -> T::AccountId { + // Sort signers for deterministic address generation + // User doesn't need to worry about order + let mut sorted_signers = signers.to_vec(); + sorted_signers.sort(); + + // Create a unique identifier from pallet id + sorted signers + threshold + nonce. // // IMPORTANT: // - Do NOT `Decode` directly from a finite byte-slice and then "fallback" to a constant @@ -1027,7 +965,8 @@ pub mod pallet { let pallet_id = T::PalletId::get(); let mut data = Vec::new(); data.extend_from_slice(&pallet_id.0); - data.extend_from_slice(&signers.encode()); + data.extend_from_slice(&sorted_signers.encode()); + data.extend_from_slice(&threshold.encode()); data.extend_from_slice(&nonce.encode()); // Hash the data and map it deterministically into an AccountId. @@ -1057,64 +996,56 @@ pub mod pallet { Ok(multisig_data) } - /// Auto-cleanup expired proposals at the start of any multisig activity - /// This is the primary cleanup mechanism for active multisigs - /// Returns deposits to original proposers and emits cleanup events - fn auto_cleanup_expired_proposals( + /// Cleanup ALL expired proposals for a specific proposer + /// + /// Iterates through all proposals in the multisig and removes expired ones + /// belonging to the specified proposer. + /// + /// Returns: number of proposals cleaned + fn cleanup_proposer_expired( multisig_address: &T::AccountId, + proposer: &T::AccountId, caller: &T::AccountId, ) -> u32 { let current_block = frame_system::Pallet::::block_number(); - let mut iterated_count = 0u32; - let mut expired_proposals: Vec<(u32, T::AccountId, BalanceOf)> = Vec::new(); - - // Iterate through all proposals to count them AND identify expired ones - for (id, proposal) in Proposals::::iter_prefix(multisig_address) { - iterated_count += 1; - if proposal.status == ProposalStatus::Active && current_block > proposal.expiry { - expired_proposals.push((id, proposal.proposer, proposal.deposit)); - } - } + let mut cleaned = 0u32; + + // Collect expired proposals to remove + let expired_proposals: Vec<(u32, T::AccountId, BalanceOf)> = + Proposals::::iter_prefix(multisig_address) + .filter_map(|(proposal_id, proposal)| { + // Only proposer's expired active proposals + if proposal.proposer == *proposer && + proposal.status == ProposalStatus::Active && + current_block > proposal.expiry + { + Some((proposal_id, proposal.proposer, proposal.deposit)) + } else { + None + } + }) + .collect(); - // Remove expired proposals and return deposits - for (id, expired_proposer, deposit) in expired_proposals.iter() { + // Remove proposals and emit events + for (proposal_id, expired_proposer, deposit) in expired_proposals { Self::remove_proposal_and_return_deposit( multisig_address, - *id, - expired_proposer, - *deposit, + proposal_id, + &expired_proposer, + deposit, ); - // Emit event for each removed proposal Self::deposit_event(Event::ProposalRemoved { multisig_address: multisig_address.clone(), - proposal_id: *id, - proposer: expired_proposer.clone(), + proposal_id, + proposer: expired_proposer, removed_by: caller.clone(), }); + + cleaned += 1; } - // Return total number of proposals iterated (not cleaned) - // This reflects the actual storage read cost - iterated_count - } - - /// Decrement proposal counters (active_proposals and per-signer counter) - /// Used when removing proposals from storage - fn decrement_proposal_counters(multisig_address: &T::AccountId, proposer: &T::AccountId) { - Multisigs::::mutate(multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { - multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - - // Decrement per-signer counter - if let Some(count) = multisig.proposals_per_signer.get_mut(proposer) { - *count = count.saturating_sub(1); - if *count == 0 { - multisig.proposals_per_signer.remove(proposer); - } - } - } - }); + cleaned } /// Remove a proposal from storage and return deposit to proposer @@ -1128,11 +1059,22 @@ pub mod pallet { // Remove from storage Proposals::::remove(multisig_address, proposal_id); + // Decrement proposal counters + Multisigs::::mutate(multisig_address, |maybe_data| { + if let Some(ref mut data) = maybe_data { + data.active_proposals = data.active_proposals.saturating_sub(1); + if let Some(count) = data.proposals_per_signer.get_mut(proposer) { + *count = count.saturating_sub(1); + // Remove entry if count reaches 0 to save storage + if *count == 0 { + data.proposals_per_signer.remove(proposer); + } + } + } + }); + // Return deposit to proposer T::Currency::unreserve(proposer, deposit); - - // Decrement counters - Self::decrement_proposal_counters(multisig_address, proposer); } /// Internal function to execute a proposal @@ -1162,13 +1104,6 @@ pub mod pallet { proposal.deposit, ); - // EFFECTS: Update multisig last_activity BEFORE external interaction - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { - multisig.last_activity = frame_system::Pallet::::block_number(); - } - }); - // INTERACTIONS: NOW execute the call as the multisig account // Proposal already removed, so reentrancy cannot affect storage let result = diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 16b28ace..b00dab49 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -1,6 +1,6 @@ //! Unit tests for pallet-multisig -use crate::{mock::*, Error, Event, GlobalNonce, Multisigs, ProposalStatus, Proposals}; +use crate::{mock::*, Error, Event, Multisigs, ProposalStatus, Proposals}; use codec::Encode; use frame_support::{assert_noop, assert_ok, traits::fungible::Mutate}; use qp_high_security::HighSecurityInspector; @@ -85,6 +85,7 @@ fn create_multisig_works() { RuntimeOrigin::signed(creator.clone()), signers.clone(), threshold, + 0, // nonce )); // Check balances @@ -93,19 +94,13 @@ fn create_multisig_works() { assert_eq!(Balances::free_balance(creator.clone()), initial_balance - fee - deposit); // Check that multisig was created - let global_nonce = GlobalNonce::::get(); - assert_eq!(global_nonce, 1); - // Get multisig address - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // Check storage let multisig_data = Multisigs::::get(&multisig_address).unwrap(); assert_eq!(multisig_data.threshold, threshold); - assert_eq!(multisig_data.nonce, 0); assert_eq!(multisig_data.signers.to_vec(), signers); - assert_eq!(multisig_data.active_proposals, 0); - assert_eq!(multisig_data.creator, creator.clone()); assert_eq!(multisig_data.deposit, deposit); // Check that event was emitted @@ -124,7 +119,12 @@ fn create_multisig_fails_with_threshold_zero() { let threshold = 0; assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), + Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers, + threshold, + 0 + ), Error::::ThresholdZero ); }); @@ -138,7 +138,12 @@ fn create_multisig_fails_with_empty_signers() { let threshold = 1; assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), + Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers, + threshold, + 0 + ), Error::::NotEnoughSigners ); }); @@ -152,7 +157,12 @@ fn create_multisig_fails_with_threshold_too_high() { let threshold = 3; // More than number of signers assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), + Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers, + threshold, + 0 + ), Error::::ThresholdTooHigh ); }); @@ -166,7 +176,12 @@ fn create_multisig_fails_with_duplicate_signers() { let threshold = 2; assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), + Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers, + threshold, + 0 + ), Error::::DuplicateSigner ); }); @@ -179,20 +194,25 @@ fn create_multiple_multisigs_increments_nonce() { let signers1 = vec![bob(), charlie()]; let signers2 = vec![bob(), dave()]; + // Create first multisig with nonce=0 assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers1.clone(), - 2 + 2, + 0 // nonce )); + + // Create second multisig with nonce=1 assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers2.clone(), - 2 + 2, + 1 // nonce - user must provide different nonce )); - // Check both multisigs exist - let multisig1 = Multisig::derive_multisig_address(&signers1, 0); - let multisig2 = Multisig::derive_multisig_address(&signers2, 1); + // Check both multisigs exist with their respective nonces + let multisig1 = Multisig::derive_multisig_address(&signers1, 2, 0); + let multisig2 = Multisig::derive_multisig_address(&signers2, 2, 1); assert!(Multisigs::::contains_key(multisig1)); assert!(Multisigs::::contains_key(multisig2)); @@ -211,10 +231,11 @@ fn propose_works() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // Propose a transaction let proposer = bob(); @@ -257,10 +278,11 @@ fn propose_fails_if_not_signer() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // Try to propose as non-signer let call = make_call(vec![1, 2, 3]); @@ -280,13 +302,15 @@ fn approve_works() { let creator = alice(); let signers = vec![bob(), charlie(), dave()]; + let threshold = 3; assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 3 + threshold, + 0 )); // Need 3 approvals - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, threshold, 0); let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( @@ -331,10 +355,11 @@ fn approve_auto_executes_when_threshold_reached() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( @@ -386,10 +411,11 @@ fn cancel_works() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); let proposer = bob(); let call = make_call(vec![1, 2, 3]); @@ -432,10 +458,11 @@ fn cancel_fails_if_already_executed() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( @@ -474,10 +501,11 @@ fn remove_expired_works_after_grace_period() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); let call = make_call(vec![1, 2, 3]); let expiry = 100; @@ -518,10 +546,11 @@ fn executed_proposals_auto_removed() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( @@ -568,10 +597,11 @@ fn remove_expired_fails_for_non_signer() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); let call = make_call(vec![1, 2, 3]); let expiry = 1000; @@ -616,10 +646,11 @@ fn claim_deposits_works() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // Bob creates 3 proposals for i in 0..3 { @@ -667,10 +698,11 @@ fn claim_deposits_works() { fn derive_multisig_address_is_deterministic() { new_test_ext().execute_with(|| { let signers = vec![bob(), charlie(), dave()]; + let threshold = 2; let nonce = 42; - let address1 = Multisig::derive_multisig_address(&signers, nonce); - let address2 = Multisig::derive_multisig_address(&signers, nonce); + let address1 = Multisig::derive_multisig_address(&signers, threshold, nonce); + let address2 = Multisig::derive_multisig_address(&signers, threshold, nonce); assert_eq!(address1, address2); }); @@ -680,9 +712,10 @@ fn derive_multisig_address_is_deterministic() { fn derive_multisig_address_different_for_different_nonce() { new_test_ext().execute_with(|| { let signers = vec![bob(), charlie(), dave()]; + let threshold = 2; - let address1 = Multisig::derive_multisig_address(&signers, 0); - let address2 = Multisig::derive_multisig_address(&signers, 1); + let address1 = Multisig::derive_multisig_address(&signers, threshold, 0); + let address2 = Multisig::derive_multisig_address(&signers, threshold, 1); assert_ne!(address1, address2); }); @@ -692,9 +725,14 @@ fn derive_multisig_address_different_for_different_nonce() { fn is_signer_works() { new_test_ext().execute_with(|| { let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(alice()), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(alice()), + signers.clone(), + 2, + 0 + )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); assert!(Multisig::is_signer(&multisig_address, &bob())); assert!(Multisig::is_signer(&multisig_address, &charlie())); @@ -712,9 +750,10 @@ fn too_many_proposals_in_storage_fails() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // MaxTotal = 20, 2 signers = 10 each // Executed/Cancelled proposals are auto-removed, so only Active count toward storage @@ -762,9 +801,10 @@ fn only_active_proposals_remain_in_storage() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // Test that only Active proposals remain in storage (Executed/Cancelled auto-removed) @@ -805,9 +845,9 @@ fn only_active_proposals_remain_in_storage() { 2000 )); } - // Bob: 10 Active (at per-signer limit) + // Bob: 10 Active (at per-signer limit: 20 total / 2 signers = 10 per signer) - // Bob cannot create 11th + // Bob cannot create 11th (exceeds per-signer limit) assert_noop!( Multisig::propose( RuntimeOrigin::signed(bob()), @@ -830,9 +870,10 @@ fn auto_cleanup_allows_new_proposals() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // Bob creates 10 proposals, all expire at block 100 (at per-signer limit) for i in 0..10 { @@ -843,7 +884,7 @@ fn auto_cleanup_allows_new_proposals() { 100 )); } - // Bob: 10 Active (at per-signer limit) + // Bob: 10 Active (at per-signer limit: 20 total / 2 signers = 10 per signer) // Bob cannot create more (at limit) assert_noop!( @@ -859,7 +900,7 @@ fn auto_cleanup_allows_new_proposals() { // Move past expiry System::set_block_number(101); - // Now Bob can create new - propose() auto-cleans expired + // Now Bob can create new - propose() auto-cleans his expired proposals assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address.clone(), @@ -867,7 +908,7 @@ fn auto_cleanup_allows_new_proposals() { 200 )); - // Verify old proposals were removed + // Verify old proposals were removed (only the new one remains) let count = crate::Proposals::::iter_prefix(&multisig_address).count(); assert_eq!(count, 1); // Only the new one remains }); @@ -883,10 +924,11 @@ fn propose_fails_with_expiry_in_past() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); let call = make_call(vec![1, 2, 3]); @@ -932,10 +974,11 @@ fn propose_fails_with_expiry_too_far() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); let call = make_call(vec![1, 2, 3]); @@ -996,10 +1039,11 @@ fn propose_charges_correct_fee_with_signer_factor() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); let proposer = bob(); let call = make_call(vec![1, 2, 3]); @@ -1034,30 +1078,37 @@ fn dissolve_multisig_works() { let creator = alice(); let signers = vec![bob(), charlie()]; let deposit = 500; - let fee = 1000; - let initial_balance = Balances::free_balance(creator.clone()); // Create assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, // threshold + 0 // nonce )); assert_eq!(Balances::reserved_balance(creator.clone()), deposit); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); - // Try to dissolve immediately (success) - assert_ok!(Multisig::dissolve_multisig( - RuntimeOrigin::signed(creator.clone()), + // Approve dissolve by Bob (1st approval) + assert_ok!(Multisig::approve_dissolve( + RuntimeOrigin::signed(bob()), multisig_address.clone() )); - // Check cleanup + // Still exists (threshold not reached) + assert!(Multisigs::::contains_key(&multisig_address)); + + // Approve dissolve by Charlie (2nd approval - threshold reached!) + assert_ok!(Multisig::approve_dissolve( + RuntimeOrigin::signed(charlie()), + multisig_address.clone() + )); + + // Check cleanup - multisig removed assert!(!Multisigs::::contains_key(&multisig_address)); - assert_eq!(Balances::reserved_balance(creator.clone()), 0); - // Balance returned (minus burned fee) - assert_eq!(Balances::free_balance(creator.clone()), initial_balance - fee); + // Deposit stays locked (burned) + assert_eq!(Balances::reserved_balance(creator.clone()), deposit); }); } @@ -1070,9 +1121,10 @@ fn dissolve_multisig_fails_with_proposals() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, // threshold + 0 // nonce )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // Create proposal let call = make_call(vec![1]); @@ -1083,12 +1135,9 @@ fn dissolve_multisig_fails_with_proposals() { 100 )); - // Try to dissolve + // Try to approve dissolve - should fail because proposals exist assert_noop!( - Multisig::dissolve_multisig( - RuntimeOrigin::signed(creator.clone()), - multisig_address.clone() - ), + Multisig::approve_dissolve(RuntimeOrigin::signed(bob()), multisig_address.clone()), Error::::ProposalsExist ); }); @@ -1103,9 +1152,10 @@ fn per_signer_proposal_limit_enforced() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - 2 + 2, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // MaxTotalProposalsInStorage = 20 // With 2 signers, each can have max 20/2 = 10 proposals @@ -1155,10 +1205,11 @@ fn propose_with_threshold_one_executes_immediately() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - threshold + threshold, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, threshold, 0); // Fund multisig account for balance transfer as Mutate<_>>::mint_into(&multisig_address, 50000).unwrap(); @@ -1202,10 +1253,6 @@ fn propose_with_threshold_one_executes_immediately() { // Verify deposit was returned to Alice (execution removes proposal) let alice_reserved = Balances::reserved_balance(alice()); assert_eq!(alice_reserved, 500); // Only MultisigDeposit, no ProposalDeposit - - // Verify active_proposals counter was decremented back to 0 - let multisig_data = Multisigs::::get(&multisig_address).unwrap(); - assert_eq!(multisig_data.active_proposals, 0); }); } @@ -1222,10 +1269,11 @@ fn propose_with_threshold_two_waits_for_approval() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - threshold + threshold, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // Fund multisig account as Mutate<_>>::mint_into(&multisig_address, 50000).unwrap(); @@ -1283,10 +1331,11 @@ fn auto_cleanup_on_approve_and_cancel() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(creator.clone()), signers.clone(), - threshold + threshold, + 0 )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 3, 0); // Create two proposals assert_ok!(Multisig::propose( @@ -1310,20 +1359,22 @@ fn auto_cleanup_on_approve_and_cancel() { // Move time forward past first proposal expiry System::set_block_number(101); - // Charlie approves proposal #1 (should trigger auto-cleanup of proposal #0) - // Note: Bob is the proposer of #1, so Charlie must approve + // Charlie approves proposal #1 + // IMPORTANT: approve() NO LONGER does auto-cleanup (removed for predictable gas) assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address.clone(), 1 )); - // Verify proposal #0 was auto-cleaned - assert!(Proposals::::get(&multisig_address, 0).is_none()); - // Proposal #1 still exists (not expired, waiting for approval) + // Verify proposal #0 still exists (NOT auto-cleaned by approve()) + assert!(Proposals::::get(&multisig_address, 0).is_some()); + // Proposal #1 still exists (waiting for more approvals) assert!(Proposals::::get(&multisig_address, 1).is_some()); - // Create another proposal that will expire + // Alice creates another proposal + // IMPORTANT: propose() DOES auto-cleanup of proposer's expired proposals + // So this will clean proposal #0 (Alice's expired proposal) assert_ok!(Multisig::propose( RuntimeOrigin::signed(alice()), multisig_address.clone(), @@ -1331,20 +1382,37 @@ fn auto_cleanup_on_approve_and_cancel() { 150 // expires at block 150 )); + // Verify proposal #0 was auto-cleaned by propose() + assert!(Proposals::::get(&multisig_address, 0).is_none()); + // Proposal #1 still exists + assert!(Proposals::::get(&multisig_address, 1).is_some()); + // Proposal #2 exists (just created) + assert!(Proposals::::get(&multisig_address, 2).is_some()); + // Move time forward past proposal #2 expiry System::set_block_number(151); - // Charlie cancels proposal #1 (should trigger auto-cleanup of proposal #2) + // Bob cancels his own proposal #1 + // IMPORTANT: cancel() NO LONGER does auto-cleanup (removed for predictable gas) assert_ok!(Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address.clone(), 1)); - // Verify proposal #2 was auto-cleaned - assert!(Proposals::::get(&multisig_address, 2).is_none()); - // Proposal #1 was cancelled + // Verify proposal #2 still exists (NOT auto-cleaned by cancel()) + assert!(Proposals::::get(&multisig_address, 2).is_some()); + // Proposal #1 was cancelled and removed assert!(Proposals::::get(&multisig_address, 1).is_none()); - // Verify active_proposals counter is correct (should be 0) - let multisig_data = Multisigs::::get(&multisig_address).unwrap(); - assert_eq!(multisig_data.active_proposals, 0); + // Alice creates another proposal - this will clean her expired #2 + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(alice()), + multisig_address.clone(), + make_call(vec![4]), + 300 + )); + + // Now Alice's expired proposal #2 should be cleaned + assert!(Proposals::::get(&multisig_address, 2).is_none()); + // Only the new proposal #3 exists + assert!(Proposals::::get(&multisig_address, 3).is_some()); }); } @@ -1365,11 +1433,8 @@ fn high_security_propose_fails_for_non_whitelisted_call() { crate::MultisigData { signers: signers.try_into().unwrap(), threshold: 2, - nonce: 0, proposal_nonce: 0, - creator: alice(), deposit: 500, - last_activity: 1, active_proposals: 0, proposals_per_signer: Default::default(), }, @@ -1404,10 +1469,11 @@ fn normal_multisig_allows_any_call() { assert_ok!(Multisig::create_multisig( RuntimeOrigin::signed(alice()), signers.clone(), - threshold + threshold, + 0 // nonce )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); // Any call should work for normal multisig let call = make_call(b"anything".to_vec()); diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 13baa5ba..3cfee7de 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -47,12 +47,12 @@ use core::marker::PhantomData; /// Weight functions needed for `pallet_multisig`. pub trait WeightInfo { - fn create_multisig() -> Weight; + fn create_multisig(s: u32, ) -> Weight; fn propose(c: u32, e: u32, ) -> Weight; fn propose_high_security(c: u32, e: u32, ) -> Weight; fn approve(c: u32, e: u32, ) -> Weight; fn approve_and_execute(c: u32, ) -> Weight; - fn cancel(c: u32, e: u32, ) -> Weight; + fn cancel() -> Weight; fn remove_expired() -> Weight; fn claim_deposits(p: u32, ) -> Weight; fn dissolve_multisig() -> Weight; @@ -61,18 +61,19 @@ pub trait WeightInfo { /// Weights for `pallet_multisig` using the Substrate node and recommended hardware. pub struct SubstrateWeight(PhantomData); impl WeightInfo for SubstrateWeight { - /// Storage: `Multisig::GlobalNonce` (r:1 w:1) - /// Proof: `Multisig::GlobalNonce` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - fn create_multisig() -> Weight { + /// The range of component `s` is `[2, 100]` (number of signers). + fn create_multisig(s: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` // Minimum execution time: 190_000_000 picoseconds. Weight::from_parts(196_000_000, 10389) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().writes(2_u64)) + // Standard Error: 1_000 + .saturating_add(Weight::from_parts(50_000, 0).saturating_mul(s.into())) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) @@ -164,21 +165,18 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn cancel(c: u32, e: u32, ) -> Weight { + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `625 + c * (1 ±0) + e * (215 ±0)` - // Estimated: `33054 + e * (16032 ±0)` + // Measured: `625` + // Estimated: `17022` // Minimum execution time: 28_000_000 picoseconds. - Weight::from_parts(33_000_000, 33054) - // Standard Error: 229 - .saturating_add(Weight::from_parts(3_462, 0).saturating_mul(c.into())) - // Standard Error: 11_632 - .saturating_add(Weight::from_parts(13_752_458, 0).saturating_mul(e.into())) - .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) + Weight::from_parts(33_000_000, 17022) + .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(e.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) @@ -231,18 +229,19 @@ impl WeightInfo for SubstrateWeight { // For backwards compatibility and tests. impl WeightInfo for () { - /// Storage: `Multisig::GlobalNonce` (r:1 w:1) - /// Proof: `Multisig::GlobalNonce` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - fn create_multisig() -> Weight { + /// The range of component `s` is `[2, 100]` (number of signers). + fn create_multisig(s: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` // Minimum execution time: 190_000_000 picoseconds. Weight::from_parts(196_000_000, 10389) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().writes(2_u64)) + // Standard Error: 1_000 + .saturating_add(Weight::from_parts(50_000, 0).saturating_mul(s.into())) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) @@ -334,21 +333,18 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. /// The range of component `e` is `[0, 200]`. - fn cancel(c: u32, e: u32, ) -> Weight { + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `625 + c * (1 ±0) + e * (215 ±0)` - // Estimated: `33054 + e * (16032 ±0)` + // Measured: `625` + // Estimated: `17022` // Minimum execution time: 28_000_000 picoseconds. - Weight::from_parts(33_000_000, 33054) - // Standard Error: 229 - .saturating_add(Weight::from_parts(3_462, 0).saturating_mul(c.into())) - // Standard Error: 11_632 - .saturating_add(Weight::from_parts(13_752_458, 0).saturating_mul(e.into())) - .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) + Weight::from_parts(33_000_000, 17022) + .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(e.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) From 19e3d19054e7518fcd52291fdab83a68ff157a5f Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 3 Feb 2026 16:30:20 +0800 Subject: [PATCH 07/15] feat: Deterministic address + simplified cleaning --- pallets/multisig/README.md | 171 +++++++----- pallets/multisig/src/benchmarking.rs | 119 ++++----- pallets/multisig/src/lib.rs | 94 +++++-- pallets/multisig/src/weights.rs | 372 ++++++++++++--------------- 4 files changed, 400 insertions(+), 356 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index 95bac3fe..e842baca 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -12,8 +12,10 @@ Basic workflow for using a multisig: ```rust // 1. Create a 2-of-3 multisig (Alice creates, Bob/Charlie/Dave are signers) -Multisig::create_multisig(Origin::signed(alice), vec![bob, charlie, dave], 2); -let multisig_addr = Multisig::derive_multisig_address(&[bob, charlie, dave], 0); +Multisig::create_multisig(Origin::signed(alice), vec![bob, charlie, dave], 2, 0); +// ^ threshold ^ nonce +let multisig_addr = Multisig::derive_multisig_address(&[bob, charlie, dave], 2, 0); +// ^ threshold ^ nonce // 2. Bob proposes a transaction let call = RuntimeCall::Balances(pallet_balances::Call::transfer { dest: eve, value: 100 }); @@ -35,21 +37,25 @@ Creates a new multisig account with deterministic address generation. **Required Parameters:** - `signers: Vec` - List of authorized signers (REQUIRED, 1 to MaxSigners) - `threshold: u32` - Number of approvals needed (REQUIRED, 1 ≤ threshold ≤ signers.len()) +- `nonce: u64` - User-provided nonce for address uniqueness (REQUIRED) **Validation:** - No duplicate signers - Threshold must be > 0 - Threshold cannot exceed number of signers - Signers count must be ≤ MaxSigners +- Multisig address (derived from signers+threshold+nonce) must not already exist **Important:** Signers are automatically sorted before storing and address generation. Order doesn't matter: -- `[alice, bob, charlie]` → sorted to `[alice, bob, charlie]` → `address_1` -- `[charlie, bob, alice]` → sorted to `[alice, bob, charlie]` → `address_1` (same!) -- To create multiple multisigs with same signers, the nonce provides uniqueness +- `[alice, bob, charlie]` + threshold=2 + nonce=0 → `address_1` +- `[charlie, bob, alice]` + threshold=2 + nonce=0 → `address_1` (same!) +- To create multiple multisigs with same signers, use different nonce: + - `signers=[alice, bob], threshold=2, nonce=0` → `address_A` + - `signers=[alice, bob], threshold=2, nonce=1` → `address_B` (different!) **Economic Costs:** - **MultisigFee**: Non-refundable fee (spam prevention) → burned -- **MultisigDeposit**: Refundable deposit (storage rent) → returned when multisig dissolved +- **MultisigDeposit**: Locked deposit (storage bond) → burned when multisig dissolved ### 2. Propose Transaction Creates a new proposal for multisig execution. @@ -69,14 +75,15 @@ Creates a new proposal for multisig execution. - Expiry must not exceed MaxExpiryDuration blocks from now (expiry ≤ current_block + MaxExpiryDuration) **Auto-Cleanup Before Creation:** -Before creating a new proposal, the system **automatically removes all expired Active proposals** for this multisig: +Before creating a new proposal, the system **automatically removes all proposer's expired Active proposals**: +- Only proposer's expired proposals are cleaned (not all proposals) - Expired proposals are identified (current_block > expiry) -- Deposits are returned to original proposers +- Deposits are returned to original proposer - Storage is cleaned up -- Counters are decremented +- Counters are decremented (active_proposals, proposals_per_signer) - Events are emitted for each removed proposal -This ensures storage is kept clean and users get their deposits back without manual intervention. +This ensures proposers get their deposits back and free up their quota automatically. **Threshold=1 Auto-Execution:** If the multisig has `threshold=1`, the proposal **executes immediately** after creation: @@ -115,6 +122,8 @@ When approval count reaches the threshold: **Economic Costs:** None (deposit immediately returned on execution) +**Note:** `approve()` does NOT perform auto-cleanup of expired proposals (removed for predictable gas costs). + ### 4. Cancel Transaction Cancels a proposal and immediately removes it from storage (proposer only). @@ -129,16 +138,18 @@ Cancels a proposal and immediately removes it from storage (proposer only). **Economic Effects:** - Proposal **immediately removed** from storage - ProposalDeposit **immediately returned** to proposer -- Counters decremented +- Counters decremented (active_proposals, proposals_per_signer) **Economic Costs:** None (deposit immediately returned) -**Note:** ProposalFee is NOT refunded - it was burned at proposal creation. +**Note:** +- ProposalFee is NOT refunded - it was burned at proposal creation. +- `cancel()` does NOT perform auto-cleanup of expired proposals (removed for predictable gas costs). ### 5. Remove Expired Manually removes expired proposals from storage. Only signers can call this. -**Important:** This is rarely needed because expired proposals are automatically cleaned up on any multisig activity (`propose()`, `approve()`, `cancel()`). +**Important:** This is rarely needed because proposer's expired proposals are automatically cleaned up when that proposer calls `propose()` or `claim_deposits()`. **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) @@ -154,16 +165,14 @@ Manually removes expired proposals from storage. Only signers can call this. **Economic Effects:** - ProposalDeposit returned to **original proposer** (not caller) - Proposal removed from storage -- Counters decremented +- Counters decremented (active_proposals, proposals_per_signer) **Economic Costs:** None (deposit always returned to proposer) -**Auto-Cleanup:** ALL expired proposals are automatically removed on any multisig activity (`propose()`, `approve()`, `cancel()`), making this function often unnecessary. +**Auto-Cleanup:** When a proposer calls `propose()`, all their expired proposals are automatically removed. This function is useful for cleaning up proposals from inactive proposers. ### 6. Claim Deposits -Batch cleanup operation to recover all expired proposal deposits. - -**Important:** This is rarely needed because expired proposals are automatically cleaned up on any multisig activity (`propose()`, `approve()`, `cancel()`). +Batch cleanup operation to recover all caller's expired proposal deposits. **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) @@ -173,34 +182,49 @@ Batch cleanup operation to recover all expired proposal deposits. - Only removes Active+Expired proposals (Executed/Cancelled already auto-removed) - Must be expired (current_block > expiry) +**Behavior:** +- Iterates through ALL proposals in the multisig +- Removes all that match: proposer=caller AND expired AND status=Active +- No iteration limits - cleans all in one call + **Economic Effects:** - Returns all eligible proposal deposits to caller - Removes all expired proposals from storage -- Counters decremented +- Counters decremented (active_proposals, proposals_per_signer) -**Economic Costs:** None (only returns deposits) +**Economic Costs:** +- Gas cost proportional to total proposals in storage (iteration cost) +- Dynamic weight refund based on actual proposals cleaned -**Auto-Cleanup:** ALL expired proposals are automatically removed on any multisig activity (`propose()`, `approve()`, `cancel()`), making this function often unnecessary. +**Note:** Same functionality as the auto-cleanup in `propose()`, but caller can trigger it manually without creating a new proposal. -### 7. Dissolve Multisig -Permanently removes a multisig and returns the creation deposit to the original creator. +### 7. Approve Dissolve +Approve dissolving a multisig account. Requires threshold approvals to complete. **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) **Pre-conditions:** +- Caller must be a signer - NO proposals can exist (any status) - Multisig balance MUST be zero -- Caller must be creator OR any signer -**Post-conditions:** -- MultisigDeposit returned to **original creator** (not caller) +**Approval Process:** +- Each signer calls `approve_dissolve()` +- Approvals are tracked in `DissolveApprovals` storage +- When threshold reached, multisig is automatically dissolved + +**Post-conditions (when threshold reached):** +- MultisigDeposit is **burned** (NOT returned) - Multisig removed from storage +- DissolveApprovals cleared - Cannot be used after dissolution -**Economic Costs:** None (returns MultisigDeposit) +**Economic Costs:** None (but deposit is burned, not returned) -**Important:** MultisigFee is NEVER returned - only the MultisigDeposit. +**Important:** +- MultisigFee and MultisigDeposit are NEVER returned - both are permanently locked/burned +- Requires threshold approvals (not just any signer or creator) ## Use Cases @@ -243,25 +267,27 @@ matches!(call, - Spam attacks reduce circulating supply - Lower transaction costs (withdraw vs transfer) -### Deposits (Refundable, locked as storage rent) +### Deposits (Locked as storage rent) **Purpose:** Compensate for on-chain storage, incentivize cleanup - **MultisigDeposit**: - Reserved on multisig creation - - Returned when multisig dissolved (via `dissolve_multisig`) + - **Burned** when multisig dissolved (via `approve_dissolve`) - Locked until no proposals exist and balance is zero - Opportunity cost incentivizes cleanup + - **NOT refundable** (acts as permanent storage bond) - **ProposalDeposit**: - Reserved on proposal creation + - **Refundable** - returned in following scenarios: - **Auto-Returned Immediately:** - When proposal executed (threshold reached) - When proposal cancelled (proposer cancels) - - **Auto-Cleanup:** ALL expired proposals are automatically removed on ANY multisig activity - - Triggered by: `propose()`, `approve()`, `cancel()` - - Deposits returned to original proposers - - No manual cleanup needed for active multisigs - - **Manual Cleanup:** Only needed for inactive multisigs via `remove_expired()` or `claim_deposits()` + - **Auto-Cleanup:** Proposer's expired proposals are automatically removed when proposer calls `propose()` + - Only proposer's proposals are cleaned (not all) + - Deposits returned to proposer + - Frees up proposer's quota automatically + - **Manual Cleanup:** For inactive proposers via `remove_expired()` or `claim_deposits()` ### Storage Limits & Configuration **Purpose:** Prevent unbounded storage growth and resource exhaustion @@ -295,17 +321,17 @@ matches!(call, Stores multisig account data: ```rust MultisigData { - signers: BoundedVec, // List of authorized signers + signers: BoundedVec, // List of authorized signers (sorted) threshold: u32, // Required approvals - nonce: u64, // Unique identifier used in address generation - deposit: Balance, // Reserved deposit (refundable) - creator: AccountId, // Who created it (receives deposit back) - last_activity: BlockNumber, // Last action timestamp (for grace period) - active_proposals: u32, // Count of open proposals (monitoring/analytics) + proposal_nonce: u32, // Counter for unique proposal IDs + deposit: Balance, // Reserved deposit (burned on dissolve) + active_proposals: u32, // Count of active proposals (for limits) proposals_per_signer: BoundedBTreeMap, // Per-signer proposal count (filibuster protection) } ``` +**Note:** Address is deterministically derived from `hash(pallet_id || sorted_signers || threshold || nonce)` where nonce is user-provided at creation time. + ### Proposals: DoubleMap Stores proposal data indexed by (multisig_address, proposal_id): ```rust @@ -321,8 +347,11 @@ ProposalData { **Important:** Only **Active** proposals are stored. Executed and Cancelled proposals are **immediately removed** from storage and their deposits are returned. Historical data is available through events (see Historical Data section below). -### GlobalNonce: u64 -Internal counter for generating unique multisig addresses. Not exposed via API. +### DissolveApprovals: Map> +Tracks which signers have approved dissolving each multisig. +- Key: Multisig address +- Value: List of signers who approved dissolution +- Cleared when multisig is dissolved or when threshold reached ## Events @@ -333,7 +362,8 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `ProposalCancelled { multisig_address, proposer, proposal_id }` - `ProposalRemoved { multisig_address, proposal_id, proposer, removed_by }` - `DepositsClaimed { multisig_address, claimer, total_returned, proposals_removed, multisig_removed }` -- `MultisigDissolved { multisig_address, caller, deposit_returned }` +- `DissolveApproved { multisig_address, approver, approvals_count }` +- `MultisigDissolved { multisig_address, deposit_returned, approvers }` ## Errors @@ -359,6 +389,8 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `TooManyProposalsPerSigner` - Caller has reached their per-signer proposal limit (`MaxTotalProposalsInStorage / signers_count`) - `ProposalNotExpired` - Proposal not yet expired (for remove_expired) - `ProposalNotActive` - Proposal is not active (already executed or cancelled) +- `ProposalsExist` - Cannot dissolve multisig while proposals exist +- `MultisigAccountNotZero` - Cannot dissolve multisig with non-zero balance ## Important Behavior @@ -382,18 +414,21 @@ approve(multisig, 1) // Approve proposal #1 ### Signer Order Doesn't Matter Signers are **automatically sorted** before address generation and storage: - Input order is irrelevant - signers are always sorted deterministically -- Address is derived from `Hash(PalletId + sorted_signers + nonce)` -- Same signers in any order = same multisig address (with same nonce) -- To create multiple multisigs with same participants, use different creation transactions (nonce auto-increments) +- Address is derived from `Hash(PalletId + sorted_signers + threshold + nonce)` +- Same signers+threshold+nonce in any order = same multisig address +- User must provide unique nonce to create multiple multisigs with same signers **Example:** ```rust -// These create the SAME multisig address (same signers, same nonce): -create_multisig([alice, bob, charlie], 2) // → multisig_addr_1 (nonce=0) -create_multisig([charlie, bob, alice], 2) // → multisig_addr_1 (SAME! nonce would be 1 but already exists) +// These create the SAME multisig address (same signers, threshold, nonce): +create_multisig([alice, bob, charlie], 2, 0) // → multisig_addr_1 +create_multisig([charlie, bob, alice], 2, 0) // → multisig_addr_1 (SAME!) + +// To create another multisig with same signers, use different nonce: +create_multisig([alice, bob, charlie], 2, 1) // → multisig_addr_2 (different!) -// To create another multisig with same signers: -create_multisig([alice, bob, charlie], 2) // → multisig_addr_2 (nonce=1, different address) +// Different threshold = different address (even with same nonce): +create_multisig([alice, bob, charlie], 3, 0) // → multisig_addr_3 (different!) ``` ## Historical Data and Event Indexing @@ -450,9 +485,9 @@ This event structure is optimized for indexing by SubSquid and similar indexers: - Auto-cleanup of expired proposals reduces storage pressure ### Storage Cleanup -- Grace period allows proposers priority cleanup -- After grace: public cleanup incentivized -- Batch cleanup via claim_deposits for efficiency +- Auto-cleanup in `propose()`: proposer's expired proposals removed automatically +- Manual cleanup via `remove_expired()`: any signer can clean any expired proposal +- Batch cleanup via `claim_deposits()`: proposer recovers all their expired deposits at once ### Economic Attacks - **Multisig Spam:** Costs MultisigFee (burned, reduces supply) @@ -491,10 +526,10 @@ impl pallet_multisig::Config for Runtime { type MaxExpiryDuration = ConstU32<100_800>; // Max proposal lifetime (~2 weeks @ 12s) // Economic parameters (example values - adjust per runtime) - type MultisigFee = ConstU128<{ 100 * MILLI_UNIT }>; // Creation barrier - type MultisigDeposit = ConstU128<{ 500 * MILLI_UNIT }>; // Storage rent - type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; // Base proposal cost - type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; // Cleanup incentive + type MultisigFee = ConstU128<{ 100 * MILLI_UNIT }>; // Creation barrier (burned) + type MultisigDeposit = ConstU128<{ 500 * MILLI_UNIT }>; // Storage bond (burned on dissolve) + type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; // Base proposal cost (burned) + type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; // Storage rent (refundable) type SignerStepFactor = Permill::from_percent(1); // Dynamic pricing (1% per signer) type PalletId = ConstPalletId(*b"py/mltsg"); @@ -591,7 +626,8 @@ at the cost of: let multisig_addr = Multisig::create_multisig( Origin::signed(alice), vec![alice, bob, charlie, dave, eve], - 3 + 3, + 0 // nonce ); // 2. Enable high-security (via multisig proposal + approvals) @@ -643,13 +679,26 @@ High-security multisigs have higher costs due to call validation: Normal multisigs automatically get refunded for unused high-security overhead. **Weight calculation:** -- `propose()` charges upfront for worst-case: `propose_high_security(call.len(), max_expired)` +- `propose()` charges upfront for worst-case: + - `propose_high_security(call.len(), MaxTotalProposalsInStorage, MaxTotalProposalsInStorage.saturating_div(2))` + - Second parameter (`i`): worst-case proposals iterated (MaxTotal) + - Third parameter (`r`): worst-case proposals removed/cleaned (MaxTotal/2, based on 2-signer minimum) +- Actual weight based on: + - Call size (actual, not worst-case) + - Proposals actually iterated during cleanup (`i`) + - Proposals actually removed/cleaned (`r`) - If multisig is NOT HS, refunds decode overhead based on actual path taken - If multisig IS HS, charges correctly for decode cost (scales with call size) +- Auto-cleanup returns both iteration count AND cleaned count for accurate weight calculation **Security notes:** - Call size is validated BEFORE decode to prevent DoS via oversized payloads - Weight formula includes O(call_size) component for decode to prevent underpayment +- **Separate charging for iteration cost (reads) vs cleanup cost (writes)**: + - `i` parameter: proposals iterated (O(N) read cost) + - `r` parameter: proposals removed (O(M) write cost, where M ≤ N) +- No refund for the iteration that actually happened (prevents undercharging attack) +- Single-pass optimization: cleanup counts proposals during iteration (no extra pass needed) - Benchmarks must be regenerated to capture accurate decode costs See `MULTISIG_REQ.md` for detailed cost breakdown and benchmarking instructions. diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index e9d98d1a..c7824cb6 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -1,10 +1,16 @@ //! Benchmarking setup for pallet-multisig use super::*; -use crate::Pallet as Multisig; +use crate::{ + BoundedApprovalsOf, BoundedCallOf, BoundedSignersOf, DissolveApprovals, MultisigDataOf, + Multisigs, Pallet as Multisig, ProposalDataOf, ProposalStatus, Proposals, +}; use alloc::vec; use frame_benchmarking::{account as benchmark_account, v2::*, BenchmarkError}; -use frame_support::traits::{fungible::Mutate, ReservableCurrency}; +use frame_support::{ + traits::{fungible::Mutate, ReservableCurrency}, + BoundedBTreeMap, +}; use frame_system::RawOrigin; const SEED: u32 = 0; @@ -66,9 +72,12 @@ mod benchmarks { #[benchmark] fn propose( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, - e: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, // expired proposals to cleanup + i: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, /* proposals iterated */ + r: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, /* proposals removed (cleaned) */ ) -> Result<(), BenchmarkError> { - // Setup: Create a multisig first + // NOTE: In benchmark we set i == r (worst-case: all expired) + let e = i.max(r); // Use max for setup to ensure enough expired proposals + // Setup: Create a multisig with 3 signers (standard test case) let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); @@ -82,22 +91,19 @@ mod benchmarks { signers.sort(); // Create multisig directly in storage - let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { signers: bounded_signers, threshold, - nonce: 0, proposal_nonce: e, // We'll insert e expired proposals - creator: caller.clone(), deposit: T::MultisigDeposit::get(), - last_activity: frame_system::Pallet::::block_number(), active_proposals: e, proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); - // Insert e expired proposals (worst case for auto-cleanup) + // Insert e expired proposals (measures iteration cost, not cleanup cost) let expired_block = 10u32.into(); for i in 0..e { let system_call = frame_system::Call::::remark { remark: vec![i as u8; 10] }; @@ -139,8 +145,11 @@ mod benchmarks { #[benchmark] fn propose_high_security( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, - e: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, // expired proposals to cleanup + i: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, /* proposals iterated */ + r: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, /* proposals removed (cleaned) */ ) -> Result<(), BenchmarkError> { + // NOTE: In benchmark we set i == r (worst-case: all expired) + let e = i.max(r); // Benchmarks propose() for high-security multisigs (includes decode + whitelist check) // This is more expensive than normal propose due to: // 1. is_high_security() check (1 DB read from ReversibleTransfers::HighSecurityAccounts) @@ -156,7 +165,7 @@ mod benchmarks { // - pallet_reversible_transfers::HighSecurityAccounts storage // - Pattern match against RuntimeCall variants - // Setup: Create a high-security multisig + // Setup: Create a high-security multisig with 3 signers (standard test case) let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); @@ -170,16 +179,13 @@ mod benchmarks { signers.sort(); // Create multisig directly in storage - let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { signers: bounded_signers, threshold, - nonce: 0, proposal_nonce: e, - creator: caller.clone(), deposit: T::MultisigDeposit::get(), - last_activity: frame_system::Pallet::::block_number(), active_proposals: e, proposals_per_signer: BoundedBTreeMap::new(), }; @@ -202,7 +208,7 @@ mod benchmarks { insert_hs_account_for_benchmark::(multisig_address.clone(), hs_data); } - // Insert e expired proposals (worst case for auto-cleanup) + // Insert e expired proposals (measures iteration cost, not cleanup cost) let expired_block = 10u32.into(); for i in 0..e { let system_call = frame_system::Call::::remark { remark: vec![i as u8; 10] }; @@ -250,8 +256,10 @@ mod benchmarks { #[benchmark] fn approve( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, - e: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, // expired proposals to cleanup ) -> Result<(), BenchmarkError> { + // NOTE: approve() does NOT do auto-cleanup (removed for predictable gas costs) + // So we don't need to test with expired proposals (e parameter removed) + // Setup: Create multisig and proposal directly in storage // Threshold is 3, so adding one more approval won't trigger execution let caller: T::AccountId = whitelisted_caller(); @@ -271,42 +279,19 @@ mod benchmarks { signers.sort(); // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { signers: bounded_signers, threshold, - nonce: 0, - proposal_nonce: e + 1, // We'll insert e expired proposals + 1 active - creator: caller.clone(), + proposal_nonce: 1, // One active proposal deposit: T::MultisigDeposit::get(), - last_activity: frame_system::Pallet::::block_number(), - active_proposals: e + 1, + active_proposals: 1, proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); - // Insert e expired proposals (worst case for auto-cleanup) - let expired_block = 10u32.into(); - for i in 0..e { - let system_call = frame_system::Call::::remark { remark: vec![i as u8; 10] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); - let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); - let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - let proposal_data = ProposalDataOf:: { - proposer: caller.clone(), - call: bounded_call, - expiry: expired_block, - approvals: bounded_approvals, - deposit: 10u32.into(), - status: ProposalStatus::Active, - }; - Proposals::::insert(&multisig_address, i, proposal_data); - } - - // Move past expiry so proposals are expired + // Set current block to avoid expiry issues frame_system::Pallet::::set_block_number(100u32.into()); // Directly insert active proposal into storage with 1 approval @@ -327,7 +312,7 @@ mod benchmarks { status: ProposalStatus::Active, }; - let proposal_id = e; // Active proposal after expired ones + let proposal_id = 0; // Single active proposal Proposals::::insert(&multisig_address, proposal_id, proposal_data); #[extrinsic_call] @@ -361,16 +346,13 @@ mod benchmarks { signers.sort(); // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { signers: bounded_signers, threshold, - nonce: 0, proposal_nonce: 1, // We'll insert proposal with id 0 - creator: caller.clone(), deposit: T::MultisigDeposit::get(), - last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, proposals_per_signer: BoundedBTreeMap::new(), }; @@ -458,7 +440,7 @@ mod benchmarks { Proposals::::insert(&multisig_address, proposal_id, proposal_data); // Reserve deposit for proposer - T::Currency::reserve(&caller, T::ProposalDeposit::get()).unwrap(); + ::Currency::reserve(&caller, T::ProposalDeposit::get()).unwrap(); #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_id); @@ -487,16 +469,13 @@ mod benchmarks { signers.sort(); // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { signers: bounded_signers, threshold, - nonce: 0, proposal_nonce: 1, // We'll insert proposal with id 0 - creator: caller.clone(), deposit: T::MultisigDeposit::get(), - last_activity: 1u32.into(), active_proposals: 1, proposals_per_signer: BoundedBTreeMap::new(), }; @@ -537,17 +516,20 @@ mod benchmarks { #[benchmark] fn claim_deposits( - p: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, /* number of expired proposals - * to cleanup */ + i: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, /* proposals iterated */ + r: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, /* proposals removed (cleaned) */ ) -> Result<(), BenchmarkError> { - // Setup: Create multisig with multiple expired proposals directly in storage + // NOTE: In benchmark we set i == r (worst-case: all expired) + let p = i.max(r); + + // Setup: Create multisig with 3 signers and multiple expired proposals let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); - fund_account::(&signer1, BalanceOf2::::from(10000u128)); - fund_account::(&signer2, BalanceOf2::::from(10000u128)); + fund_account::(&signer1, BalanceOf2::::from(100000u128)); + fund_account::(&signer2, BalanceOf2::::from(100000u128)); let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; let threshold = 2u32; @@ -569,6 +551,10 @@ mod benchmarks { Multisigs::::insert(&multisig_address, multisig_data); // Create multiple expired proposals directly in storage + // NOTE: All proposals are expired and belong to caller, so: + // - total_iterated = p (what we measure) + // - cleaned = p (side effect) + // We charge for iteration cost, not cleanup count! let expiry = 10u32.into(); // Already expired for i in 0..p { @@ -618,7 +604,7 @@ mod benchmarks { signers.sort(); // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let deposit = T::MultisigDeposit::get(); @@ -626,23 +612,26 @@ mod benchmarks { ::Currency::reserve(&caller, deposit)?; let multisig_data = MultisigDataOf:: { - signers: bounded_signers, + signers: bounded_signers.clone(), threshold, - nonce: 0, proposal_nonce: 0, - creator: caller.clone(), deposit, - last_activity: frame_system::Pallet::::block_number(), active_proposals: 0, // No proposals proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); + // Add first approval (signer1) + let mut approvals = BoundedApprovalsOf::::default(); + approvals.try_push(signer1.clone()).unwrap(); + DissolveApprovals::::insert(&multisig_address, approvals); + // Ensure multisig address has zero balance (required for dissolution) // Don't fund it at all + // Benchmark the final approval that triggers dissolution #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), multisig_address.clone()); + approve_dissolve(RawOrigin::Signed(caller.clone()), multisig_address.clone()); // Verify multisig was removed assert!(!Multisigs::::contains_key(&multisig_address)); diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 240e4ed3..8a2cdd2a 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -5,15 +5,20 @@ //! //! ## Features //! -//! - Create multisig addresses with configurable thresholds +//! - Create multisig addresses with deterministic generation (signers + threshold + user-provided +//! nonce) //! - Propose transactions for multisig approval //! - Approve proposed transactions -//! - Execute transactions once threshold is reached +//! - Execute transactions once threshold is reached (automatic) +//! - Auto-cleanup of proposer's expired proposals on propose() +//! - Per-signer proposal limits for filibuster protection //! //! ## Data Structures //! -//! - **Multisig**: Contains signers, threshold, and global nonce -//! - **Proposal**: Contains transaction data, proposer, expiry, and approvals +//! - **MultisigData**: Contains signers, threshold, proposal counter, deposit, and per-signer +//! tracking +//! - **ProposalData**: Contains transaction data, proposer, expiry, approvals, deposit, and status +//! - **DissolveApprovals**: Tracks threshold-based approvals for multisig dissolution #![cfg_attr(not(feature = "std"), no_std)] @@ -376,14 +381,21 @@ pub mod pallet { #[pallet::call] impl Pallet { - /// Create a new multisig account + /// Create a new multisig account with deterministic address /// /// Parameters: /// - `signers`: List of accounts that can sign for this multisig /// - `threshold`: Number of approvals required to execute transactions + /// - `nonce`: User-provided nonce for address uniqueness /// - /// The multisig address is derived from a hash of all signers + global nonce. - /// The creator must pay a non-refundable fee (burned). + /// The multisig address is deterministically derived from: + /// hash(pallet_id || sorted_signers || threshold || nonce) + /// + /// Signers are automatically sorted before hashing, so order doesn't matter. + /// + /// Economic costs: + /// - MultisigFee: burned immediately (spam prevention) + /// - MultisigDeposit: locked until dissolution, then burned (storage bond) #[pallet::call_index(0)] #[pallet::weight(::WeightInfo::create_multisig(signers.len() as u32))] pub fn create_multisig( @@ -483,9 +495,10 @@ pub mod pallet { /// High-security multisigs incur additional cost for decode + whitelist check. #[pallet::call_index(1)] #[pallet::weight(::WeightInfo::propose_high_security( - call.len() as u32, - T::MaxTotalProposalsInStorage::get() - ))] + call.len() as u32, + T::MaxTotalProposalsInStorage::get(), // Worst-case iterated + T::MaxTotalProposalsInStorage::get().saturating_div(2) // Worst-case cleaned (MaxTotal / 2 signers) + ))] #[allow(clippy::useless_conversion)] pub fn propose( origin: OriginFor, @@ -519,7 +532,11 @@ pub mod pallet { // Auto-cleanup ALL proposer's expired proposals before creating new one // This is the primary cleanup mechanism for active multisigs - let _cleaned = Self::cleanup_proposer_expired(&multisig_address, &proposer, &proposer); + // Returns: (cleaned_count, total_proposals_iterated) + // - cleaned_count: proposals removed (O(M) write cost) + // - total_proposals_iterated: proposals iterated (O(N) read cost, where N >= M) + let (cleaned, total_proposals_iterated) = + Self::cleanup_proposer_expired(&multisig_address, &proposer, &proposer); // Reload multisig data after potential cleanup let multisig_data = @@ -532,6 +549,7 @@ pub mod pallet { // Check total proposals in storage limit (Active + Executed + Cancelled) // This incentivizes cleanup and prevents unbounded storage growth + // NOTE: After cleanup, so this is the NEW count (post-cleanup) let total_proposals_in_storage = Proposals::::iter_prefix(&multisig_address).count() as u32; ensure!( @@ -644,14 +662,20 @@ pub mod pallet { Self::do_execute(multisig_address, proposal_id, proposal)?; } - // Calculate actual weight based on call size - // Note: cleanup cost is included in base weight (worst-case all proposals) + // Calculate actual weight based on call size, proposals iterated, and cleaned + // Accurate charging based on actual work performed: + // - total_proposals_iterated: O(N) read cost + // - cleaned: O(M) write cost (where M <= N) let actual_weight = if is_high_security { // Used high-security path (decode + whitelist check) - ::WeightInfo::propose_high_security(call_size, 0) + ::WeightInfo::propose_high_security( + call_size, + total_proposals_iterated, + cleaned, + ) } else { // Used normal path (no decode overhead) - ::WeightInfo::propose(call_size, 0) + ::WeightInfo::propose(call_size, total_proposals_iterated, cleaned) }; Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) @@ -667,8 +691,9 @@ pub mod pallet { /// - `proposal_id`: ID (nonce) of the proposal to approve /// /// Weight: Charges for MAX call size, refunds based on actual + /// NOTE: approve() does NOT do auto-cleanup (removed for predictable gas costs) #[pallet::call_index(2)] - #[pallet::weight(::WeightInfo::approve(T::MaxCallSize::get(), 0))] + #[pallet::weight(::WeightInfo::approve(T::MaxCallSize::get()))] #[allow(clippy::useless_conversion)] pub fn approve( origin: OriginFor, @@ -686,7 +711,7 @@ pub mod pallet { // Calculate actual weight based on real call size let actual_call_size = proposal.call.len() as u32; - let actual_weight = ::WeightInfo::approve(actual_call_size, 0); + let actual_weight = ::WeightInfo::approve(actual_call_size); // Check if not expired let current_block = frame_system::Pallet::::block_number(); @@ -831,8 +856,9 @@ pub mod pallet { /// Returns all proposal deposits to the proposer in a single transaction. #[pallet::call_index(5)] #[pallet::weight(::WeightInfo::claim_deposits( - T::MaxTotalProposalsInStorage::get() -))] + T::MaxTotalProposalsInStorage::get(), // Worst-case iterated + T::MaxTotalProposalsInStorage::get().saturating_div(2) // Worst-case cleaned (MaxTotal / 2 signers) + ))] #[allow(clippy::useless_conversion)] pub fn claim_deposits( origin: OriginFor, @@ -841,7 +867,9 @@ pub mod pallet { let caller = ensure_signed(origin)?; // Cleanup ALL caller's expired proposals - let cleaned = Self::cleanup_proposer_expired(&multisig_address, &caller, &caller); + // Returns: (cleaned_count, total_proposals_iterated) + let (cleaned, total_proposals_iterated) = + Self::cleanup_proposer_expired(&multisig_address, &caller, &caller); let deposit_per_proposal = T::ProposalDeposit::get(); let total_returned = deposit_per_proposal.saturating_mul(cleaned.into()); @@ -855,8 +883,12 @@ pub mod pallet { multisig_removed: false, }); - // Return actual weight based on number of proposals cleaned - let actual_weight = ::WeightInfo::claim_deposits(cleaned); + // Return actual weight based on proposals iterated and cleaned + // Accurate charging based on actual work performed: + // - total_proposals_iterated: O(N) read cost + // - cleaned: O(M) write cost (where M <= N) + let actual_weight = + ::WeightInfo::claim_deposits(total_proposals_iterated, cleaned); Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } @@ -1001,19 +1033,25 @@ pub mod pallet { /// Iterates through all proposals in the multisig and removes expired ones /// belonging to the specified proposer. /// - /// Returns: number of proposals cleaned + /// Returns: (cleaned_count, total_proposals_iterated) + /// - cleaned_count: number of proposals actually removed + /// - total_proposals_iterated: total proposals that existed before cleanup (for weight + /// calculation) fn cleanup_proposer_expired( multisig_address: &T::AccountId, proposer: &T::AccountId, caller: &T::AccountId, - ) -> u32 { + ) -> (u32, u32) { let current_block = frame_system::Pallet::::block_number(); - let mut cleaned = 0u32; + let mut total_iterated = 0u32; // Collect expired proposals to remove + // IMPORTANT: We count ALL proposals during iteration (for weight calculation) let expired_proposals: Vec<(u32, T::AccountId, BalanceOf)> = Proposals::::iter_prefix(multisig_address) .filter_map(|(proposal_id, proposal)| { + total_iterated += 1; // Count every proposal we iterate through + // Only proposer's expired active proposals if proposal.proposer == *proposer && proposal.status == ProposalStatus::Active && @@ -1026,6 +1064,8 @@ pub mod pallet { }) .collect(); + let cleaned = expired_proposals.len() as u32; + // Remove proposals and emit events for (proposal_id, expired_proposer, deposit) in expired_proposals { Self::remove_proposal_and_return_deposit( @@ -1041,11 +1081,9 @@ pub mod pallet { proposer: expired_proposer, removed_by: caller.clone(), }); - - cleaned += 1; } - cleaned + (cleaned, total_iterated) } /// Remove a proposal from storage and return deposit to proposer diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 3cfee7de..66cd886f 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-01-30, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-02-03, STEPS: `20`, REPEAT: `5`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -31,10 +31,10 @@ // --chain=dev // --pallet=pallet_multisig // --extrinsic=* -// --steps=50 -// --repeat=20 -// --template=./.maintain/frame-weight-template.hbs +// --steps=20 +// --repeat=5 // --output=./pallets/multisig/src/weights.rs +// --template=./.maintain/frame-weight-template.hbs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -48,13 +48,13 @@ use core::marker::PhantomData; /// Weight functions needed for `pallet_multisig`. pub trait WeightInfo { fn create_multisig(s: u32, ) -> Weight; - fn propose(c: u32, e: u32, ) -> Weight; - fn propose_high_security(c: u32, e: u32, ) -> Weight; - fn approve(c: u32, e: u32, ) -> Weight; + fn propose(c: u32, i: u32, r: u32, ) -> Weight; + fn propose_high_security(c: u32, i: u32, r: u32, ) -> Weight; + fn approve(c: u32, ) -> Weight; fn approve_and_execute(c: u32, ) -> Weight; fn cancel() -> Weight; fn remove_expired() -> Weight; - fn claim_deposits(p: u32, ) -> Weight; + fn claim_deposits(i: u32, r: u32, ) -> Weight; fn dissolve_multisig() -> Weight; } @@ -62,335 +62,303 @@ pub trait WeightInfo { pub struct SubstrateWeight(PhantomData); impl WeightInfo for SubstrateWeight { /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// The range of component `s` is `[2, 100]` (number of signers). + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// The range of component `s` is `[2, 100]`. fn create_multisig(s: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `10389` + // Estimated: `10345` // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(196_000_000, 10389) - // Standard Error: 1_000 - .saturating_add(Weight::from_parts(50_000, 0).saturating_mul(s.into())) + Weight::from_parts(121_304_558, 10345) + // Standard Error: 109_009 + .saturating_add(Weight::from_parts(4_791_535, 0).saturating_mul(s.into())) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `e` is `[0, 200]`. - fn propose(c: u32, e: u32, ) -> Weight { + /// The range of component `i` is `[0, 200]`. + /// The range of component `r` is `[0, 200]`. + fn propose(_c: u32, i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `610 + e * (215 ±0)` - // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 77_000_000 picoseconds. - Weight::from_parts(61_728_409, 17022) - // Standard Error: 508 - .saturating_add(Weight::from_parts(3_081, 0).saturating_mul(c.into())) - // Standard Error: 25_716 - .saturating_add(Weight::from_parts(14_354_502, 0).saturating_mul(e.into())) - .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) - .saturating_add(T::DbWeight::get().writes(2_u64)) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(e.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + // Measured: `43638` + // Estimated: `3223422` + // Minimum execution time: 2_883_000_000 picoseconds. + Weight::from_parts(2_925_855_213, 3223422) + // Standard Error: 90_143 + .saturating_add(Weight::from_parts(97_097, 0).saturating_mul(i.into())) + // Standard Error: 90_143 + .saturating_add(Weight::from_parts(407_116, 0).saturating_mul(r.into())) + .saturating_add(T::DbWeight::get().reads(203_u64)) + .saturating_add(T::DbWeight::get().writes(202_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `e` is `[0, 200]`. - fn propose_high_security(c: u32, e: u32, ) -> Weight { + /// The range of component `i` is `[0, 200]`. + /// The range of component `r` is `[0, 200]`. + fn propose_high_security(_c: u32, _i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `770 + e * (215 ±0)` - // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 46_000_000 picoseconds. - Weight::from_parts(46_636_454, 17022) - // Standard Error: 504 - .saturating_add(Weight::from_parts(282, 0).saturating_mul(c.into())) - // Standard Error: 25_536 - .saturating_add(Weight::from_parts(14_620_974, 0).saturating_mul(e.into())) - .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) - .saturating_add(T::DbWeight::get().writes(2_u64)) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(e.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + // Measured: `43798` + // Estimated: `3223422` + // Minimum execution time: 2_841_000_000 picoseconds. + Weight::from_parts(2_950_917_970, 3223422) + // Standard Error: 64_654 + .saturating_add(Weight::from_parts(3_237, 0).saturating_mul(r.into())) + .saturating_add(T::DbWeight::get().reads(203_u64)) + .saturating_add(T::DbWeight::get().writes(202_u64)) } - /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:202 w:201) + /// Storage: `Multisig::Multisigs` (r:1 w:0) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `e` is `[0, 200]`. - fn approve(_c: u32, e: u32, ) -> Weight { + fn approve(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `657 + c * (1 ±0) + e * (215 ±0)` - // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(49_516_534, 33054) - // Standard Error: 26_699 - .saturating_add(Weight::from_parts(14_041_478, 0).saturating_mul(e.into())) - .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) - .saturating_add(T::DbWeight::get().writes(2_u64)) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(e.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + // Measured: `722 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 12_000_000 picoseconds. + Weight::from_parts(12_385_822, 17022) + // Standard Error: 25 + .saturating_add(Weight::from_parts(275, 0).saturating_mul(c.into())) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:2 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve_and_execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `790 + c * (1 ±0)` - // Estimated: `33054` - // Minimum execution time: 30_000_000 picoseconds. - Weight::from_parts(35_686_901, 33054) - // Standard Error: 95 - .saturating_add(Weight::from_parts(1_060, 0).saturating_mul(c.into())) - .saturating_add(T::DbWeight::get().reads(3_u64)) + // Measured: `690 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 24_000_000 picoseconds. + Weight::from_parts(23_705_179, 17022) + // Standard Error: 93 + .saturating_add(Weight::from_parts(1_139, 0).saturating_mul(c.into())) + .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } - /// Storage: `Multisig::Proposals` (r:202 w:201) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// The range of component `c` is `[0, 10140]`. - /// The range of component `e` is `[0, 200]`. - /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `625` + // Measured: `698` // Estimated: `17022` - // Minimum execution time: 28_000_000 picoseconds. - Weight::from_parts(33_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(23_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `764` + // Measured: `720` // Estimated: `17022` // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) + Weight::from_parts(22_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:201 w:200) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// The range of component `p` is `[1, 200]`. - fn claim_deposits(p: u32, ) -> Weight { + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// The range of component `i` is `[1, 200]`. + /// The range of component `r` is `[1, 200]`. + fn claim_deposits(i: u32, _r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `625 + p * (237 ±0)` - // Estimated: `17022 + p * (16032 ±0)` - // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(27_178_826, 17022) - // Standard Error: 23_441 - .saturating_add(Weight::from_parts(13_739_383, 0).saturating_mul(p.into())) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(p.into()))) - .saturating_add(T::DbWeight::get().writes(1_u64)) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(p.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(p.into())) + // Measured: `47886` + // Estimated: `3223422` + // Minimum execution time: 2_713_000_000 picoseconds. + Weight::from_parts(2_785_799_383, 3223422) + // Standard Error: 67_503 + .saturating_add(Weight::from_parts(204_713, 0).saturating_mul(i.into())) + .saturating_add(T::DbWeight::get().reads(202_u64)) + .saturating_add(T::DbWeight::get().writes(201_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:0) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:1 w:0) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Multisig::DissolveApprovals` (r:1 w:1) + /// Proof: `Multisig::DissolveApprovals` (`max_values`: None, `max_size`: Some(3250), added: 5725, mode: `MaxEncodedLen`) fn dissolve_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `538` + // Measured: `671` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) - .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) + // Minimum execution time: 17_000_000 picoseconds. + Weight::from_parts(18_000_000, 17022) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) } } // For backwards compatibility and tests. impl WeightInfo for () { /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// The range of component `s` is `[2, 100]` (number of signers). + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// The range of component `s` is `[2, 100]`. fn create_multisig(s: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `10389` + // Estimated: `10345` // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(196_000_000, 10389) - // Standard Error: 1_000 - .saturating_add(Weight::from_parts(50_000, 0).saturating_mul(s.into())) + Weight::from_parts(121_304_558, 10345) + // Standard Error: 109_009 + .saturating_add(Weight::from_parts(4_791_535, 0).saturating_mul(s.into())) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `e` is `[0, 200]`. - fn propose(c: u32, e: u32, ) -> Weight { + /// The range of component `i` is `[0, 200]`. + /// The range of component `r` is `[0, 200]`. + fn propose(_c: u32, i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `610 + e * (215 ±0)` - // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 77_000_000 picoseconds. - Weight::from_parts(61_728_409, 17022) - // Standard Error: 508 - .saturating_add(Weight::from_parts(3_081, 0).saturating_mul(c.into())) - // Standard Error: 25_716 - .saturating_add(Weight::from_parts(14_354_502, 0).saturating_mul(e.into())) - .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) - .saturating_add(RocksDbWeight::get().writes(2_u64)) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(e.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + // Measured: `43638` + // Estimated: `3223422` + // Minimum execution time: 2_883_000_000 picoseconds. + Weight::from_parts(2_925_855_213, 3223422) + // Standard Error: 90_143 + .saturating_add(Weight::from_parts(97_097, 0).saturating_mul(i.into())) + // Standard Error: 90_143 + .saturating_add(Weight::from_parts(407_116, 0).saturating_mul(r.into())) + .saturating_add(RocksDbWeight::get().reads(203_u64)) + .saturating_add(RocksDbWeight::get().writes(202_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `e` is `[0, 200]`. - fn propose_high_security(c: u32, e: u32, ) -> Weight { + /// The range of component `i` is `[0, 200]`. + /// The range of component `r` is `[0, 200]`. + fn propose_high_security(_c: u32, _i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `770 + e * (215 ±0)` - // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 46_000_000 picoseconds. - Weight::from_parts(46_636_454, 17022) - // Standard Error: 504 - .saturating_add(Weight::from_parts(282, 0).saturating_mul(c.into())) - // Standard Error: 25_536 - .saturating_add(Weight::from_parts(14_620_974, 0).saturating_mul(e.into())) - .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) - .saturating_add(RocksDbWeight::get().writes(2_u64)) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(e.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + // Measured: `43798` + // Estimated: `3223422` + // Minimum execution time: 2_841_000_000 picoseconds. + Weight::from_parts(2_950_917_970, 3223422) + // Standard Error: 64_654 + .saturating_add(Weight::from_parts(3_237, 0).saturating_mul(r.into())) + .saturating_add(RocksDbWeight::get().reads(203_u64)) + .saturating_add(RocksDbWeight::get().writes(202_u64)) } - /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:202 w:201) + /// Storage: `Multisig::Multisigs` (r:1 w:0) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `e` is `[0, 200]`. - fn approve(_c: u32, e: u32, ) -> Weight { + fn approve(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `657 + c * (1 ±0) + e * (215 ±0)` - // Estimated: `33054 + e * (16032 ±0)` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(49_516_534, 33054) - // Standard Error: 26_699 - .saturating_add(Weight::from_parts(14_041_478, 0).saturating_mul(e.into())) - .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) - .saturating_add(RocksDbWeight::get().writes(2_u64)) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(e.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + // Measured: `722 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 12_000_000 picoseconds. + Weight::from_parts(12_385_822, 17022) + // Standard Error: 25 + .saturating_add(Weight::from_parts(275, 0).saturating_mul(c.into())) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:2 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve_and_execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `790 + c * (1 ±0)` - // Estimated: `33054` - // Minimum execution time: 30_000_000 picoseconds. - Weight::from_parts(35_686_901, 33054) - // Standard Error: 95 - .saturating_add(Weight::from_parts(1_060, 0).saturating_mul(c.into())) - .saturating_add(RocksDbWeight::get().reads(3_u64)) + // Measured: `690 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 24_000_000 picoseconds. + Weight::from_parts(23_705_179, 17022) + // Standard Error: 93 + .saturating_add(Weight::from_parts(1_139, 0).saturating_mul(c.into())) + .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } - /// Storage: `Multisig::Proposals` (r:202 w:201) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// The range of component `c` is `[0, 10140]`. - /// The range of component `e` is `[0, 200]`. - /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `625` + // Measured: `698` // Estimated: `17022` - // Minimum execution time: 28_000_000 picoseconds. - Weight::from_parts(33_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(23_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `764` + // Measured: `720` // Estimated: `17022` // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) + Weight::from_parts(22_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:201 w:200) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) - /// The range of component `p` is `[1, 200]`. - fn claim_deposits(p: u32, ) -> Weight { + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// The range of component `i` is `[1, 200]`. + /// The range of component `r` is `[1, 200]`. + fn claim_deposits(i: u32, _r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `625 + p * (237 ±0)` - // Estimated: `17022 + p * (16032 ±0)` - // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(27_178_826, 17022) - // Standard Error: 23_441 - .saturating_add(Weight::from_parts(13_739_383, 0).saturating_mul(p.into())) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(p.into()))) - .saturating_add(RocksDbWeight::get().writes(1_u64)) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(p.into()))) - .saturating_add(Weight::from_parts(0, 16032).saturating_mul(p.into())) + // Measured: `47886` + // Estimated: `3223422` + // Minimum execution time: 2_713_000_000 picoseconds. + Weight::from_parts(2_785_799_383, 3223422) + // Standard Error: 67_503 + .saturating_add(Weight::from_parts(204_713, 0).saturating_mul(i.into())) + .saturating_add(RocksDbWeight::get().reads(202_u64)) + .saturating_add(RocksDbWeight::get().writes(201_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:0) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:1 w:0) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Multisig::DissolveApprovals` (r:1 w:1) + /// Proof: `Multisig::DissolveApprovals` (`max_values`: None, `max_size`: Some(3250), added: 5725, mode: `MaxEncodedLen`) fn dissolve_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `538` + // Measured: `671` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) - .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().writes(1_u64)) + // Minimum execution time: 17_000_000 picoseconds. + Weight::from_parts(18_000_000, 17022) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) } } From 6ced61053cb53308e01008914129fafe78f5a2a9 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Thu, 5 Feb 2026 14:46:42 +0800 Subject: [PATCH 08/15] fix: Deposits for multisig --- pallets/multisig/README.md | 16 +- pallets/multisig/src/benchmarking.rs | 8 + pallets/multisig/src/lib.rs | 36 +++-- pallets/multisig/src/migration.rs | 0 pallets/multisig/src/tests.rs | 5 +- pallets/multisig/src/weights.rs | 218 ++++++++++++++------------- 6 files changed, 161 insertions(+), 122 deletions(-) create mode 100644 pallets/multisig/src/migration.rs diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index e842baca..bc99e271 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -54,8 +54,8 @@ Creates a new multisig account with deterministic address generation. - `signers=[alice, bob], threshold=2, nonce=1` → `address_B` (different!) **Economic Costs:** -- **MultisigFee**: Non-refundable fee (spam prevention) → burned -- **MultisigDeposit**: Locked deposit (storage bond) → burned when multisig dissolved +- **MultisigFee**: Non-refundable fee (spam prevention) → burned immediately +- **MultisigDeposit**: Reserved deposit (storage bond) → returned to creator when multisig dissolved ### 2. Propose Transaction Creates a new proposal for multisig execution. @@ -215,15 +215,16 @@ Approve dissolving a multisig account. Requires threshold approvals to complete. - When threshold reached, multisig is automatically dissolved **Post-conditions (when threshold reached):** -- MultisigDeposit is **burned** (NOT returned) +- MultisigDeposit is **returned to creator** - Multisig removed from storage - DissolveApprovals cleared - Cannot be used after dissolution -**Economic Costs:** None (but deposit is burned, not returned) +**Economic Costs:** None (deposit returned to creator) **Important:** -- MultisigFee and MultisigDeposit are NEVER returned - both are permanently locked/burned +- MultisigFee is NEVER returned (burned on creation) +- MultisigDeposit IS returned to the original creator - Requires threshold approvals (not just any signer or creator) ## Use Cases @@ -321,10 +322,11 @@ matches!(call, Stores multisig account data: ```rust MultisigData { + creator: AccountId, // Original creator (receives deposit back on dissolve) signers: BoundedVec, // List of authorized signers (sorted) threshold: u32, // Required approvals proposal_nonce: u32, // Counter for unique proposal IDs - deposit: Balance, // Reserved deposit (burned on dissolve) + deposit: Balance, // Reserved deposit (returned to creator on dissolve) active_proposals: u32, // Count of active proposals (for limits) proposals_per_signer: BoundedBTreeMap, // Per-signer proposal count (filibuster protection) } @@ -527,7 +529,7 @@ impl pallet_multisig::Config for Runtime { // Economic parameters (example values - adjust per runtime) type MultisigFee = ConstU128<{ 100 * MILLI_UNIT }>; // Creation barrier (burned) - type MultisigDeposit = ConstU128<{ 500 * MILLI_UNIT }>; // Storage bond (burned on dissolve) + type MultisigDeposit = ConstU128<{ 500 * MILLI_UNIT }>; // Storage bond (returned to creator on dissolve) type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; // Base proposal cost (burned) type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; // Storage rent (refundable) type SignerStepFactor = Permill::from_percent(1); // Dynamic pricing (1% per signer) diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index c7824cb6..8d9fbb99 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -94,6 +94,7 @@ mod benchmarks { let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { + creator: caller.clone(), signers: bounded_signers, threshold, proposal_nonce: e, // We'll insert e expired proposals @@ -182,6 +183,7 @@ mod benchmarks { let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { + creator: caller.clone(), signers: bounded_signers, threshold, proposal_nonce: e, @@ -282,6 +284,7 @@ mod benchmarks { let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { + creator: caller.clone(), signers: bounded_signers, threshold, proposal_nonce: 1, // One active proposal @@ -349,6 +352,7 @@ mod benchmarks { let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { + creator: caller.clone(), signers: bounded_signers, threshold, proposal_nonce: 1, // We'll insert proposal with id 0 @@ -410,6 +414,7 @@ mod benchmarks { let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { + creator: caller.clone(), signers: bounded_signers, threshold, proposal_nonce: 1, @@ -472,6 +477,7 @@ mod benchmarks { let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { + creator: caller.clone(), signers: bounded_signers, threshold, proposal_nonce: 1, // We'll insert proposal with id 0 @@ -541,6 +547,7 @@ mod benchmarks { let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { + creator: caller.clone(), signers: bounded_signers, threshold, proposal_nonce: p, // We'll insert p proposals with ids 0..p-1 @@ -612,6 +619,7 @@ mod benchmarks { ::Currency::reserve(&caller, deposit)?; let multisig_data = MultisigDataOf:: { + creator: caller.clone(), signers: bounded_signers.clone(), threshold, proposal_nonce: 0, diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 8a2cdd2a..7276c46b 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -45,7 +45,9 @@ use sp_runtime::RuntimeDebug; /// Multisig account data #[derive(Encode, Decode, MaxEncodedLen, Clone, TypeInfo, RuntimeDebug, PartialEq, Eq)] -pub struct MultisigData { +pub struct MultisigData { + /// Account that created this multisig (receives deposit back on dissolve) + pub creator: AccountId, /// List of signers who can approve transactions pub signers: BoundedSigners, /// Number of approvals required to execute a transaction @@ -61,11 +63,16 @@ pub struct MultisigData { pub proposals_per_signer: BoundedProposalsPerSigner, } -impl Default - for MultisigData +impl< + AccountId: Default, + BoundedSigners: Default, + Balance: Default, + BoundedProposalsPerSigner: Default, + > Default for MultisigData { fn default() -> Self { Self { + creator: Default::default(), signers: Default::default(), threshold: 1, proposal_nonce: 0, @@ -220,8 +227,12 @@ pub mod pallet { BoundedBTreeMap<::AccountId, u32, ::MaxSigners>; /// Type alias for MultisigData with proper bounds - pub type MultisigDataOf = - MultisigData, BalanceOf, BoundedProposalsPerSignerOf>; + pub type MultisigDataOf = MultisigData< + ::AccountId, + BoundedSignersOf, + BalanceOf, + BoundedProposalsPerSignerOf, + >; /// Type alias for ProposalData with proper bounds pub type ProposalDataOf = ProposalData< @@ -318,7 +329,7 @@ pub mod pallet { /// A multisig account was dissolved (threshold reached) MultisigDissolved { multisig_address: T::AccountId, - deposit_returned: BalanceOf, + deposit_returned: T::AccountId, // Creator who receives the deposit back approvers: Vec, }, } @@ -395,7 +406,7 @@ pub mod pallet { /// /// Economic costs: /// - MultisigFee: burned immediately (spam prevention) - /// - MultisigDeposit: locked until dissolution, then burned (storage bond) + /// - MultisigDeposit: reserved until dissolution, then returned to creator (storage bond) #[pallet::call_index(0)] #[pallet::weight(::WeightInfo::create_multisig(signers.len() as u32))] pub fn create_multisig( @@ -454,6 +465,7 @@ pub mod pallet { Multisigs::::insert( &multisig_address, MultisigDataOf:: { + creator: creator.clone(), signers: bounded_signers.clone(), threshold, proposal_nonce: 0, @@ -903,7 +915,7 @@ pub mod pallet { /// - Multisig account balance must be zero /// /// When threshold is reached: - /// - Deposit is burned (stays locked forever) + /// - Deposit is returned to creator /// - Multisig storage is removed #[pallet::call_index(6)] #[pallet::weight(::WeightInfo::dissolve_multisig())] @@ -951,15 +963,19 @@ pub mod pallet { if approvals_count >= multisig_data.threshold { // Threshold reached - dissolve multisig let deposit = multisig_data.deposit; + let creator = multisig_data.creator.clone(); - // Remove multisig from storage (deposit stays locked/burned) + // Remove multisig from storage Multisigs::::remove(&multisig_address); DissolveApprovals::::remove(&multisig_address); + // Return deposit to creator + T::Currency::unreserve(&creator, deposit); + // Emit dissolved event Self::deposit_event(Event::MultisigDissolved { multisig_address, - deposit_returned: deposit, + deposit_returned: creator, approvers: approvals.to_vec(), }); } else { diff --git a/pallets/multisig/src/migration.rs b/pallets/multisig/src/migration.rs new file mode 100644 index 00000000..e69de29b diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index b00dab49..c0809a00 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -1107,8 +1107,8 @@ fn dissolve_multisig_works() { // Check cleanup - multisig removed assert!(!Multisigs::::contains_key(&multisig_address)); - // Deposit stays locked (burned) - assert_eq!(Balances::reserved_balance(creator.clone()), deposit); + // Deposit was returned to creator (unreserved) + assert_eq!(Balances::reserved_balance(creator.clone()), 0); }); } @@ -1431,6 +1431,7 @@ fn high_security_propose_fails_for_non_whitelisted_call() { Multisigs::::insert( &multisig_address, crate::MultisigData { + creator: alice(), signers: signers.try_into().unwrap(), threshold: 2, proposal_nonce: 0, diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 66cd886f..13b7ce8e 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-02-03, STEPS: `20`, REPEAT: `5`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-02-05, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -31,8 +31,8 @@ // --chain=dev // --pallet=pallet_multisig // --extrinsic=* -// --steps=20 -// --repeat=5 +// --steps=50 +// --repeat=20 // --output=./pallets/multisig/src/weights.rs // --template=./.maintain/frame-weight-template.hbs @@ -62,21 +62,21 @@ pub trait WeightInfo { pub struct SubstrateWeight(PhantomData); impl WeightInfo for SubstrateWeight { /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// The range of component `s` is `[2, 100]`. fn create_multisig(s: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `10345` - // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(121_304_558, 10345) - // Standard Error: 109_009 - .saturating_add(Weight::from_parts(4_791_535, 0).saturating_mul(s.into())) + // Estimated: `10377` + // Minimum execution time: 188_000_000 picoseconds. + Weight::from_parts(119_431_673, 10377) + // Standard Error: 34_938 + .saturating_add(Weight::from_parts(4_822_869, 0).saturating_mul(s.into())) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) @@ -84,21 +84,21 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. /// The range of component `i` is `[0, 200]`. /// The range of component `r` is `[0, 200]`. - fn propose(_c: u32, i: u32, r: u32, ) -> Weight { + fn propose(c: u32, _i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `43638` + // Measured: `43670` // Estimated: `3223422` - // Minimum execution time: 2_883_000_000 picoseconds. - Weight::from_parts(2_925_855_213, 3223422) - // Standard Error: 90_143 - .saturating_add(Weight::from_parts(97_097, 0).saturating_mul(i.into())) - // Standard Error: 90_143 - .saturating_add(Weight::from_parts(407_116, 0).saturating_mul(r.into())) + // Minimum execution time: 2_843_000_000 picoseconds. + Weight::from_parts(3_015_729_300, 3223422) + // Standard Error: 530 + .saturating_add(Weight::from_parts(559, 0).saturating_mul(c.into())) + // Standard Error: 26_838 + .saturating_add(Weight::from_parts(62_860, 0).saturating_mul(r.into())) .saturating_add(T::DbWeight::get().reads(203_u64)) .saturating_add(T::DbWeight::get().writes(202_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) @@ -106,94 +106,100 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. /// The range of component `i` is `[0, 200]`. /// The range of component `r` is `[0, 200]`. - fn propose_high_security(_c: u32, _i: u32, r: u32, ) -> Weight { + fn propose_high_security(c: u32, i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `43798` + // Measured: `43830` // Estimated: `3223422` - // Minimum execution time: 2_841_000_000 picoseconds. - Weight::from_parts(2_950_917_970, 3223422) - // Standard Error: 64_654 - .saturating_add(Weight::from_parts(3_237, 0).saturating_mul(r.into())) + // Minimum execution time: 2_767_000_000 picoseconds. + Weight::from_parts(2_948_221_272, 3223422) + // Standard Error: 396 + .saturating_add(Weight::from_parts(235, 0).saturating_mul(c.into())) + // Standard Error: 20_043 + .saturating_add(Weight::from_parts(75_978, 0).saturating_mul(i.into())) + // Standard Error: 20_043 + .saturating_add(Weight::from_parts(15_851, 0).saturating_mul(r.into())) .saturating_add(T::DbWeight::get().reads(203_u64)) .saturating_add(T::DbWeight::get().writes(202_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:0) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `722 + c * (1 ±0)` + // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(12_385_822, 17022) - // Standard Error: 25 - .saturating_add(Weight::from_parts(275, 0).saturating_mul(c.into())) + Weight::from_parts(13_337_928, 17022) + // Standard Error: 26 + .saturating_add(Weight::from_parts(386, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve_and_execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `690 + c * (1 ±0)` + // Measured: `722 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 24_000_000 picoseconds. - Weight::from_parts(23_705_179, 17022) - // Standard Error: 93 - .saturating_add(Weight::from_parts(1_139, 0).saturating_mul(c.into())) + Weight::from_parts(24_421_125, 17022) + // Standard Error: 46 + .saturating_add(Weight::from_parts(1_073, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `698` + // Measured: `730` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `720` + // Measured: `752` // Estimated: `17022` // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + Weight::from_parts(25_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:201 w:200) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// The range of component `i` is `[1, 200]`. /// The range of component `r` is `[1, 200]`. - fn claim_deposits(i: u32, _r: u32, ) -> Weight { + fn claim_deposits(i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `47886` + // Measured: `47918` // Estimated: `3223422` - // Minimum execution time: 2_713_000_000 picoseconds. - Weight::from_parts(2_785_799_383, 3223422) - // Standard Error: 67_503 - .saturating_add(Weight::from_parts(204_713, 0).saturating_mul(i.into())) + // Minimum execution time: 2_697_000_000 picoseconds. + Weight::from_parts(2_809_251_030, 3223422) + // Standard Error: 21_665 + .saturating_add(Weight::from_parts(69_674, 0).saturating_mul(i.into())) + // Standard Error: 21_665 + .saturating_add(Weight::from_parts(35_005, 0).saturating_mul(r.into())) .saturating_add(T::DbWeight::get().reads(202_u64)) .saturating_add(T::DbWeight::get().writes(201_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:0) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:1 w:0) @@ -202,10 +208,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::DissolveApprovals` (`max_values`: None, `max_size`: Some(3250), added: 5725, mode: `MaxEncodedLen`) fn dissolve_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `671` + // Measured: `703` // Estimated: `17022` - // Minimum execution time: 17_000_000 picoseconds. - Weight::from_parts(18_000_000, 17022) + // Minimum execution time: 26_000_000 picoseconds. + Weight::from_parts(27_000_000, 17022) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -214,21 +220,21 @@ impl WeightInfo for SubstrateWeight { // For backwards compatibility and tests. impl WeightInfo for () { /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// The range of component `s` is `[2, 100]`. fn create_multisig(s: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `10345` - // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(121_304_558, 10345) - // Standard Error: 109_009 - .saturating_add(Weight::from_parts(4_791_535, 0).saturating_mul(s.into())) + // Estimated: `10377` + // Minimum execution time: 188_000_000 picoseconds. + Weight::from_parts(119_431_673, 10377) + // Standard Error: 34_938 + .saturating_add(Weight::from_parts(4_822_869, 0).saturating_mul(s.into())) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) @@ -236,21 +242,21 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. /// The range of component `i` is `[0, 200]`. /// The range of component `r` is `[0, 200]`. - fn propose(_c: u32, i: u32, r: u32, ) -> Weight { + fn propose(c: u32, _i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `43638` + // Measured: `43670` // Estimated: `3223422` - // Minimum execution time: 2_883_000_000 picoseconds. - Weight::from_parts(2_925_855_213, 3223422) - // Standard Error: 90_143 - .saturating_add(Weight::from_parts(97_097, 0).saturating_mul(i.into())) - // Standard Error: 90_143 - .saturating_add(Weight::from_parts(407_116, 0).saturating_mul(r.into())) + // Minimum execution time: 2_843_000_000 picoseconds. + Weight::from_parts(3_015_729_300, 3223422) + // Standard Error: 530 + .saturating_add(Weight::from_parts(559, 0).saturating_mul(c.into())) + // Standard Error: 26_838 + .saturating_add(Weight::from_parts(62_860, 0).saturating_mul(r.into())) .saturating_add(RocksDbWeight::get().reads(203_u64)) .saturating_add(RocksDbWeight::get().writes(202_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:201 w:201) @@ -258,94 +264,100 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. /// The range of component `i` is `[0, 200]`. /// The range of component `r` is `[0, 200]`. - fn propose_high_security(_c: u32, _i: u32, r: u32, ) -> Weight { + fn propose_high_security(c: u32, i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `43798` + // Measured: `43830` // Estimated: `3223422` - // Minimum execution time: 2_841_000_000 picoseconds. - Weight::from_parts(2_950_917_970, 3223422) - // Standard Error: 64_654 - .saturating_add(Weight::from_parts(3_237, 0).saturating_mul(r.into())) + // Minimum execution time: 2_767_000_000 picoseconds. + Weight::from_parts(2_948_221_272, 3223422) + // Standard Error: 396 + .saturating_add(Weight::from_parts(235, 0).saturating_mul(c.into())) + // Standard Error: 20_043 + .saturating_add(Weight::from_parts(75_978, 0).saturating_mul(i.into())) + // Standard Error: 20_043 + .saturating_add(Weight::from_parts(15_851, 0).saturating_mul(r.into())) .saturating_add(RocksDbWeight::get().reads(203_u64)) .saturating_add(RocksDbWeight::get().writes(202_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:0) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `722 + c * (1 ±0)` + // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(12_385_822, 17022) - // Standard Error: 25 - .saturating_add(Weight::from_parts(275, 0).saturating_mul(c.into())) + Weight::from_parts(13_337_928, 17022) + // Standard Error: 26 + .saturating_add(Weight::from_parts(386, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve_and_execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `690 + c * (1 ±0)` + // Measured: `722 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 24_000_000 picoseconds. - Weight::from_parts(23_705_179, 17022) - // Standard Error: 93 - .saturating_add(Weight::from_parts(1_139, 0).saturating_mul(c.into())) + Weight::from_parts(24_421_125, 17022) + // Standard Error: 46 + .saturating_add(Weight::from_parts(1_073, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `698` + // Measured: `730` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `720` + // Measured: `752` // Estimated: `17022` // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + Weight::from_parts(25_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:201 w:200) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// The range of component `i` is `[1, 200]`. /// The range of component `r` is `[1, 200]`. - fn claim_deposits(i: u32, _r: u32, ) -> Weight { + fn claim_deposits(i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `47886` + // Measured: `47918` // Estimated: `3223422` - // Minimum execution time: 2_713_000_000 picoseconds. - Weight::from_parts(2_785_799_383, 3223422) - // Standard Error: 67_503 - .saturating_add(Weight::from_parts(204_713, 0).saturating_mul(i.into())) + // Minimum execution time: 2_697_000_000 picoseconds. + Weight::from_parts(2_809_251_030, 3223422) + // Standard Error: 21_665 + .saturating_add(Weight::from_parts(69_674, 0).saturating_mul(i.into())) + // Standard Error: 21_665 + .saturating_add(Weight::from_parts(35_005, 0).saturating_mul(r.into())) .saturating_add(RocksDbWeight::get().reads(202_u64)) .saturating_add(RocksDbWeight::get().writes(201_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6880), added: 9355, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:0) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:1 w:0) @@ -354,10 +366,10 @@ impl WeightInfo for () { /// Proof: `Multisig::DissolveApprovals` (`max_values`: None, `max_size`: Some(3250), added: 5725, mode: `MaxEncodedLen`) fn dissolve_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `671` + // Measured: `703` // Estimated: `17022` - // Minimum execution time: 17_000_000 picoseconds. - Weight::from_parts(18_000_000, 17022) + // Minimum execution time: 26_000_000 picoseconds. + Weight::from_parts(27_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } From 6e0d8fd458110d1c0587ab279df38f3a19dd98ef Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Mon, 9 Feb 2026 10:54:40 +0800 Subject: [PATCH 09/15] fix: Dynamic weight + benchmarks refactor --- pallets/multisig/src/benchmarking.rs | 309 +++++++++++++++------------ pallets/multisig/src/lib.rs | 126 ++++++++--- pallets/multisig/src/weights.rs | 222 ++++++++++--------- 3 files changed, 394 insertions(+), 263 deletions(-) diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 8d9fbb99..56e83485 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -6,12 +6,8 @@ use crate::{ Multisigs, Pallet as Multisig, ProposalDataOf, ProposalStatus, Proposals, }; use alloc::vec; -use frame_benchmarking::{account as benchmark_account, v2::*, BenchmarkError}; -use frame_support::{ - traits::{fungible::Mutate, ReservableCurrency}, - BoundedBTreeMap, -}; -use frame_system::RawOrigin; +use frame_benchmarking::v2::*; +use frame_support::{traits::fungible::Mutate, BoundedBTreeMap}; const SEED: u32 = 0; @@ -36,11 +32,13 @@ where mod benchmarks { use super::*; use codec::Encode; + use frame_support::traits::ReservableCurrency; + use frame_system::RawOrigin; + /// Benchmark `create_multisig` extrinsic. + /// Parameter: s = signers count #[benchmark] - fn create_multisig( - s: Linear<2, { T::MaxSigners::get() }>, // number of signers - ) -> Result<(), BenchmarkError> { + fn create_multisig(s: Linear<2, { T::MaxSigners::get() }>) -> Result<(), BenchmarkError> { let caller: T::AccountId = whitelisted_caller(); // Fund the caller with enough balance for deposit @@ -48,8 +46,8 @@ mod benchmarks { // Create signers (including caller) let mut signers = vec![caller.clone()]; - for i in 0..s.saturating_sub(1) { - let signer: T::AccountId = benchmark_account("signer", i, SEED); + for n in 0..s.saturating_sub(1) { + let signer: T::AccountId = account("signer", n, SEED); signers.push(signer); } let threshold = 2u32; @@ -69,20 +67,33 @@ mod benchmarks { Ok(()) } + /// Benchmark `propose` extrinsic. + /// Parameters: c = call size, i = iterated proposals, r = removed (cleaned) proposals #[benchmark] fn propose( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, - i: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, /* proposals iterated */ - r: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, /* proposals removed (cleaned) */ + i: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, + r: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, ) -> Result<(), BenchmarkError> { - // NOTE: In benchmark we set i == r (worst-case: all expired) - let e = i.max(r); // Use max for setup to ensure enough expired proposals - // Setup: Create a multisig with 3 signers (standard test case) + // Can't clean more proposals than we iterate + let cleaned_target = (r as u32).min(i); + // Total proposals = iterated (maps directly to iteration parameter) + // Edge case: when i == Max and cleaned_target == 0, all proposals remain + // after cleanup. propose() checks `total_in_storage < Max`, so cap to Max - 1. + // In runtime this combination results in TooManyProposalsInStorage error, + // but we still need the data point for accurate regression at nearby values. + let total_proposals = if i == T::MaxTotalProposalsInStorage::get() && cleaned_target == 0 { + T::MaxTotalProposalsInStorage::get() - 1 + } else { + i + }; + + // Setup: Create a multisig with 3 signers (standard test case) let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); - let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); - let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + let signer1: T::AccountId = account("signer1", 0, SEED); + let signer2: T::AccountId = account("signer2", 1, SEED); fund_account::(&signer1, BalanceOf2::::from(100000u128)); fund_account::(&signer2, BalanceOf2::::from(100000u128)); @@ -97,81 +108,90 @@ mod benchmarks { creator: caller.clone(), signers: bounded_signers, threshold, - proposal_nonce: e, // We'll insert e expired proposals + proposal_nonce: total_proposals, deposit: T::MultisigDeposit::get(), - active_proposals: e, + active_proposals: total_proposals, proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); - // Insert e expired proposals (measures iteration cost, not cleanup cost) + // Build proposal template once - only expiry varies per proposal + let template_call: BoundedCallOf = { + let system_call = frame_system::Call::::remark { remark: vec![0u8; 10] }; + ::RuntimeCall::from(system_call).encode().try_into().unwrap() + }; + let template_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); + + // Insert proposals: first `cleaned_target` are expired, rest are non-expired. + // This separates iteration cost (read all total_proposals) from cleanup cost + // (delete cleaned_target). let expired_block = 10u32.into(); - for i in 0..e { - let system_call = frame_system::Call::::remark { remark: vec![i as u8; 10] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); - let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); - let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - let proposal_data = ProposalDataOf:: { - proposer: caller.clone(), - call: bounded_call, - expiry: expired_block, - approvals: bounded_approvals, - deposit: 10u32.into(), - status: ProposalStatus::Active, - }; - Proposals::::insert(&multisig_address, i, proposal_data); + let future_block = 999999u32.into(); + for idx in 0..total_proposals { + let expiry = if idx < cleaned_target { expired_block } else { future_block }; + Proposals::::insert( + &multisig_address, + idx, + ProposalDataOf:: { + proposer: caller.clone(), + call: template_call.clone(), + expiry, + approvals: template_approvals.clone(), + deposit: 10u32.into(), + status: ProposalStatus::Active, + }, + ); } - // Move past expiry so proposals are expired + // Move past expired_block but before future_block frame_system::Pallet::::set_block_number(100u32.into()); - // Create a new proposal (will auto-cleanup all e expired proposals) - let system_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); + // Create a new proposal (will auto-cleanup expired proposals only) + let new_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; + let encoded_call = ::RuntimeCall::from(new_call).encode(); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); - // Verify new proposal was created and expired ones were cleaned + // Verify: non-expired proposals remain + 1 new proposal let multisig = Multisigs::::get(&multisig_address).unwrap(); - assert_eq!(multisig.active_proposals, 1); // Only new proposal remains + let expected_active = (total_proposals - cleaned_target) + 1; + assert_eq!(multisig.active_proposals, expected_active); Ok(()) } + /// Benchmark `propose` for high-security multisigs (includes decode + whitelist check). + /// Parameters: c = call size, i = iterated proposals, r = removed (cleaned) proposals #[benchmark] fn propose_high_security( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, - i: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, /* proposals iterated */ - r: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, /* proposals removed (cleaned) */ + i: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, + r: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, ) -> Result<(), BenchmarkError> { - // NOTE: In benchmark we set i == r (worst-case: all expired) - let e = i.max(r); - // Benchmarks propose() for high-security multisigs (includes decode + whitelist check) - // This is more expensive than normal propose due to: + // Can't clean more proposals than we iterate + let cleaned_target = (r as u32).min(i); + // Total proposals = i (maps directly to iteration parameter) + // Edge case: when i == Max and cleaned_target == 0, cap to Max - 1 + // (same reasoning as propose) + let total_proposals = if i == T::MaxTotalProposalsInStorage::get() && cleaned_target == 0 { + T::MaxTotalProposalsInStorage::get() - 1 + } else { + i + }; + + // More expensive than normal propose due to: // 1. is_high_security() check (1 DB read from ReversibleTransfers::HighSecurityAccounts) // 2. RuntimeCall decode (O(c) overhead - scales with call size) // 3. is_whitelisted() pattern matching - // - // NOTE: This benchmark measures the OVERHEAD of high-security checks, - // not the functionality. The actual HighSecurity implementation is runtime-specific. - // Mock implementation in tests would need to recognize this multisig as HS, - // but for weight measurement, we're benchmarking the worst-case: full decode path. - // - // In production, the runtime's HighSecurityConfig will check: - // - pallet_reversible_transfers::HighSecurityAccounts storage - // - Pattern match against RuntimeCall variants // Setup: Create a high-security multisig with 3 signers (standard test case) let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); - let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); - let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + let signer1: T::AccountId = account("signer1", 0, SEED); + let signer2: T::AccountId = account("signer2", 1, SEED); fund_account::(&signer1, BalanceOf2::::from(100000u128)); fund_account::(&signer2, BalanceOf2::::from(100000u128)); @@ -186,9 +206,9 @@ mod benchmarks { creator: caller.clone(), signers: bounded_signers, threshold, - proposal_nonce: e, + proposal_nonce: total_proposals, deposit: T::MultisigDeposit::get(), - active_proposals: e, + active_proposals: total_proposals, proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); @@ -210,35 +230,42 @@ mod benchmarks { insert_hs_account_for_benchmark::(multisig_address.clone(), hs_data); } - // Insert e expired proposals (measures iteration cost, not cleanup cost) + // Build proposal template once - only expiry varies per proposal + let template_call: BoundedCallOf = { + let system_call = frame_system::Call::::remark { remark: vec![0u8; 10] }; + ::RuntimeCall::from(system_call).encode().try_into().unwrap() + }; + let template_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); + + // Insert proposals: first `cleaned_target` are expired, rest are non-expired. + // This separates iteration cost (read all total_proposals) from cleanup cost + // (delete cleaned_target). let expired_block = 10u32.into(); - for i in 0..e { - let system_call = frame_system::Call::::remark { remark: vec![i as u8; 10] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); - let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); - let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - let proposal_data = ProposalDataOf:: { - proposer: caller.clone(), - call: bounded_call, - expiry: expired_block, - approvals: bounded_approvals, - deposit: 10u32.into(), - status: ProposalStatus::Active, - }; - Proposals::::insert(&multisig_address, i, proposal_data); + let future_block = 999999u32.into(); + for idx in 0..total_proposals { + let expiry = if idx < cleaned_target { expired_block } else { future_block }; + Proposals::::insert( + &multisig_address, + idx, + ProposalDataOf:: { + proposer: caller.clone(), + call: template_call.clone(), + expiry, + approvals: template_approvals.clone(), + deposit: 10u32.into(), + status: ProposalStatus::Active, + }, + ); } - // Move past expiry so proposals are expired + // Move past expired_block but before future_block frame_system::Pallet::::set_block_number(100u32.into()); // Create a whitelisted call for HS multisig // Using system::remark with variable size to measure decode cost O(c) // NOTE: system::remark is whitelisted ONLY in runtime-benchmarks mode - let system_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); + let new_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; + let encoded_call = ::RuntimeCall::from(new_call).encode(); // Verify we're testing with actual variable size assert!(encoded_call.len() >= c as usize, "Call size should scale with parameter c"); @@ -248,28 +275,31 @@ mod benchmarks { #[extrinsic_call] propose(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); - // Verify new proposal was created and expired ones were cleaned + // Verify: non-expired proposals remain + 1 new proposal let multisig = Multisigs::::get(&multisig_address).unwrap(); - assert_eq!(multisig.active_proposals, 1); + let expected_active = (total_proposals - cleaned_target) + 1; + assert_eq!(multisig.active_proposals, expected_active); Ok(()) } + /// Benchmark `approve` extrinsic (without execution). + /// Parameter: c = call size (stored proposal call) #[benchmark] fn approve( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { // NOTE: approve() does NOT do auto-cleanup (removed for predictable gas costs) - // So we don't need to test with expired proposals (e parameter removed) + // So we don't need to test with expired proposals // Setup: Create multisig and proposal directly in storage // Threshold is 3, so adding one more approval won't trigger execution let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); - let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); - let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); - let signer3: T::AccountId = benchmark_account("signer3", 2, SEED); + let signer1: T::AccountId = account("signer1", 0, SEED); + let signer2: T::AccountId = account("signer2", 1, SEED); + let signer3: T::AccountId = account("signer3", 2, SEED); fund_account::(&signer1, BalanceOf2::::from(100000u128)); fund_account::(&signer2, BalanceOf2::::from(100000u128)); fund_account::(&signer3, BalanceOf2::::from(100000u128)); @@ -329,16 +359,17 @@ mod benchmarks { Ok(()) } + /// Benchmark `approve` when it triggers auto-execution (threshold reached). + /// Parameter: c = call size #[benchmark] fn approve_and_execute( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { - // Benchmarks approve() when it triggers auto-execution (threshold reached) let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(10000u128)); - let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); - let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + let signer1: T::AccountId = account("signer1", 0, SEED); + let signer2: T::AccountId = account("signer2", 1, SEED); fund_account::(&signer1, BalanceOf2::::from(10000u128)); fund_account::(&signer2, BalanceOf2::::from(10000u128)); @@ -401,8 +432,8 @@ mod benchmarks { let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); - let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); - let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + let signer1: T::AccountId = account("signer1", 0, SEED); + let signer2: T::AccountId = account("signer2", 1, SEED); let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; let threshold = 2u32; @@ -462,8 +493,8 @@ mod benchmarks { let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(10000u128)); - let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); - let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + let signer1: T::AccountId = account("signer1", 0, SEED); + let signer2: T::AccountId = account("signer2", 1, SEED); fund_account::(&signer1, BalanceOf2::::from(10000u128)); fund_account::(&signer2, BalanceOf2::::from(10000u128)); @@ -520,20 +551,27 @@ mod benchmarks { Ok(()) } + /// Benchmark `claim_deposits` extrinsic. + /// Parameters: i = iterated proposals, r = removed (cleaned) proposals #[benchmark] fn claim_deposits( - i: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, /* proposals iterated */ - r: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, /* proposals removed (cleaned) */ + i: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, + r: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, ) -> Result<(), BenchmarkError> { - // NOTE: In benchmark we set i == r (worst-case: all expired) - let p = i.max(r); + // cleaned_target = min(r, i): can't clean more proposals than we iterate + let cleaned_target = (r as u32).min(i); + + // Total proposals = i (maps directly to iteration parameter) + // No edge case needed here: claim_deposits doesn't create a new proposal, + // so there's no `total < Max` check to worry about. + let total_proposals = i; - // Setup: Create multisig with 3 signers and multiple expired proposals + // Setup: Create multisig with 3 signers and multiple proposals let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); - let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); - let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + let signer1: T::AccountId = account("signer1", 0, SEED); + let signer2: T::AccountId = account("signer2", 1, SEED); fund_account::(&signer1, BalanceOf2::::from(100000u128)); fund_account::(&signer2, BalanceOf2::::from(100000u128)); @@ -550,47 +588,50 @@ mod benchmarks { creator: caller.clone(), signers: bounded_signers, threshold, - proposal_nonce: p, // We'll insert p proposals with ids 0..p-1 + proposal_nonce: total_proposals, deposit: T::MultisigDeposit::get(), - active_proposals: p, + active_proposals: total_proposals, proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); - // Create multiple expired proposals directly in storage - // NOTE: All proposals are expired and belong to caller, so: - // - total_iterated = p (what we measure) - // - cleaned = p (side effect) - // We charge for iteration cost, not cleanup count! - let expiry = 10u32.into(); // Already expired - - for i in 0..p { - let system_call = frame_system::Call::::remark { remark: vec![i as u8; 32] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); - let bounded_call: BoundedCallOf = encoded_call.clone().try_into().unwrap(); - let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - let proposal_data = ProposalDataOf:: { - proposer: caller.clone(), - call: bounded_call, - expiry, - approvals: bounded_approvals, - deposit: 10u32.into(), - status: ProposalStatus::Active, - }; + // Build proposal template once - only expiry varies per proposal + let template_call: BoundedCallOf = { + let system_call = frame_system::Call::::remark { remark: vec![0u8; 32] }; + ::RuntimeCall::from(system_call).encode().try_into().unwrap() + }; + let template_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - Proposals::::insert(&multisig_address, i, proposal_data); + // Insert proposals: first `cleaned_target` are expired, rest are non-expired. + // This separates iteration cost (read all total_proposals) from cleanup cost + // (delete cleaned_target). + let expired_block = 10u32.into(); + let future_block = 999999u32.into(); + for idx in 0..total_proposals { + let expiry = if idx < cleaned_target { expired_block } else { future_block }; + Proposals::::insert( + &multisig_address, + idx, + ProposalDataOf:: { + proposer: caller.clone(), + call: template_call.clone(), + expiry, + approvals: template_approvals.clone(), + deposit: 10u32.into(), + status: ProposalStatus::Active, + }, + ); } - // Move past expiry + // Move past expired_block but before future_block frame_system::Pallet::::set_block_number(100u32.into()); #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone()); - // Verify all expired proposals were cleaned up - assert_eq!(Proposals::::iter_key_prefix(&multisig_address).count(), 0); + // Verify: only non-expired proposals remain + let remaining = Proposals::::iter_key_prefix(&multisig_address).count() as u32; + assert_eq!(remaining, total_proposals - cleaned_target); Ok(()) } @@ -601,8 +642,8 @@ mod benchmarks { let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(10000u128)); - let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); - let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + let signer1: T::AccountId = account("signer1", 0, SEED); + let signer2: T::AccountId = account("signer2", 1, SEED); let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; let threshold = 2u32; diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 7276c46b..995b49c3 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -122,7 +122,8 @@ pub mod pallet { use codec::Encode; use frame_support::{ dispatch::{ - DispatchResult, DispatchResultWithPostInfo, GetDispatchInfo, Pays, PostDispatchInfo, + DispatchErrorWithPostInfo, DispatchResult, DispatchResultWithPostInfo, GetDispatchInfo, + Pays, PostDispatchInfo, }, pallet_prelude::*, traits::{Currency, ReservableCurrency}, @@ -523,23 +524,45 @@ pub mod pallet { // CRITICAL: Check call size FIRST, before any heavy operations (especially decode) // This prevents DoS via oversized payloads that would be decoded before size validation let call_size = call.len() as u32; - ensure!(call_size <= T::MaxCallSize::get(), Error::::CallTooLarge); + if call_size > T::MaxCallSize::get() { + return Self::err_with_weight(Error::::CallTooLarge, 0); + } - // Check if proposer is a signer - let multisig_data = - Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; - ensure!(multisig_data.signers.contains(&proposer), Error::::NotASigner); + // Check if proposer is a signer (1 read: Multisigs) + let multisig_data = Multisigs::::get(&multisig_address).ok_or_else(|| { + DispatchErrorWithPostInfo { + post_info: PostDispatchInfo { + actual_weight: Some(T::DbWeight::get().reads(1)), + pays_fee: Pays::Yes, + }, + error: Error::::MultisigNotFound.into(), + } + })?; + if !multisig_data.signers.contains(&proposer) { + return Self::err_with_weight(Error::::NotASigner, 1); + } // High-security check: if multisig is high-security, only whitelisted calls allowed // Size already validated above, so decode is now safe + // (2 reads: Multisigs + HighSecurityAccounts) let is_high_security = T::HighSecurity::is_high_security(&multisig_address); if is_high_security { - let decoded_call = ::RuntimeCall::decode(&mut &call[..]) - .map_err(|_| Error::::InvalidCall)?; - ensure!( - T::HighSecurity::is_whitelisted(&decoded_call), - Error::::CallNotAllowedForHighSecurityMultisig - ); + let decoded_call = + ::RuntimeCall::decode(&mut &call[..]).map_err(|_| { + DispatchErrorWithPostInfo { + post_info: PostDispatchInfo { + actual_weight: Some(T::DbWeight::get().reads(2)), + pays_fee: Pays::Yes, + }, + error: Error::::InvalidCall.into(), + } + })?; + if !T::HighSecurity::is_whitelisted(&decoded_call) { + return Self::err_with_weight( + Error::::CallNotAllowedForHighSecurityMultisig, + 2, + ); + } } // Auto-cleanup ALL proposer's expired proposals before creating new one @@ -714,23 +737,46 @@ pub mod pallet { ) -> DispatchResultWithPostInfo { let approver = ensure_signed(origin)?; - // Check if approver is a signer - let multisig_data = Self::ensure_is_signer(&multisig_address, &approver)?; + // Check if approver is a signer (1 read: Multisigs) + let multisig_data = Multisigs::::get(&multisig_address).ok_or_else(|| { + DispatchErrorWithPostInfo { + post_info: PostDispatchInfo { + actual_weight: Some(T::DbWeight::get().reads(1)), + pays_fee: Pays::Yes, + }, + error: Error::::MultisigNotFound.into(), + } + })?; + if !multisig_data.signers.contains(&approver) { + return Self::err_with_weight(Error::::NotASigner, 1); + } - // Get proposal - let mut proposal = Proposals::::get(&multisig_address, proposal_id) - .ok_or(Error::::ProposalNotFound)?; + // Get proposal (2 reads: Multisigs + Proposals) + let mut proposal = + Proposals::::get(&multisig_address, proposal_id).ok_or_else(|| { + DispatchErrorWithPostInfo { + post_info: PostDispatchInfo { + actual_weight: Some(T::DbWeight::get().reads(2)), + pays_fee: Pays::Yes, + }, + error: Error::::ProposalNotFound.into(), + } + })?; // Calculate actual weight based on real call size let actual_call_size = proposal.call.len() as u32; let actual_weight = ::WeightInfo::approve(actual_call_size); - // Check if not expired + // Check if not expired (2 reads already performed) let current_block = frame_system::Pallet::::block_number(); - ensure!(current_block <= proposal.expiry, Error::::ProposalExpired); + if current_block > proposal.expiry { + return Self::err_with_weight(Error::::ProposalExpired, 2); + } - // Check if already approved - ensure!(!proposal.approvals.contains(&approver), Error::::AlreadyApproved); + // Check if already approved (2 reads already performed) + if proposal.approvals.contains(&approver) { + return Self::err_with_weight(Error::::AlreadyApproved, 2); + } // Add approval proposal @@ -776,15 +822,27 @@ pub mod pallet { ) -> DispatchResultWithPostInfo { let canceller = ensure_signed(origin)?; - // Get proposal - let proposal = Proposals::::get(&multisig_address, proposal_id) - .ok_or(Error::::ProposalNotFound)?; + // Get proposal (1 read: Proposals) + let proposal = + Proposals::::get(&multisig_address, proposal_id).ok_or_else(|| { + DispatchErrorWithPostInfo { + post_info: PostDispatchInfo { + actual_weight: Some(T::DbWeight::get().reads(1)), + pays_fee: Pays::Yes, + }, + error: Error::::ProposalNotFound.into(), + } + })?; - // Check if caller is the proposer - ensure!(canceller == proposal.proposer, Error::::NotProposer); + // Check if caller is the proposer (1 read already performed) + if canceller != proposal.proposer { + return Self::err_with_weight(Error::::NotProposer, 1); + } - // Check if proposal is still active - ensure!(proposal.status == ProposalStatus::Active, Error::::ProposalNotActive); + // Check if proposal is still active (1 read already performed) + if proposal.status != ProposalStatus::Active { + return Self::err_with_weight(Error::::ProposalNotActive, 1); + } // Remove proposal from storage and return deposit immediately Self::remove_proposal_and_return_deposit( @@ -988,6 +1046,18 @@ pub mod pallet { } impl Pallet { + /// Return an error with actual weight consumed instead of charging full upfront weight. + /// Use for early exits where minimal work was performed. + fn err_with_weight(error: Error, reads: u64) -> DispatchResultWithPostInfo { + Err(DispatchErrorWithPostInfo { + post_info: PostDispatchInfo { + actual_weight: Some(T::DbWeight::get().reads(reads)), + pays_fee: Pays::Yes, + }, + error: error.into(), + }) + } + /// Derive a deterministic multisig address from signers, threshold, and nonce /// /// The address is computed as: hash(pallet_id || sorted_signers || threshold || nonce) diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 13b7ce8e..d521c3b5 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-02-05, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-02-09, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -68,10 +68,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 188_000_000 picoseconds. - Weight::from_parts(119_431_673, 10377) - // Standard Error: 34_938 - .saturating_add(Weight::from_parts(4_822_869, 0).saturating_mul(s.into())) + // Minimum execution time: 186_000_000 picoseconds. + Weight::from_parts(115_597_142, 10377) + // Standard Error: 35_292 + .saturating_add(Weight::from_parts(4_856_016, 0).saturating_mul(s.into())) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -84,18 +84,23 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. /// The range of component `i` is `[0, 200]`. /// The range of component `r` is `[0, 200]`. - fn propose(c: u32, _i: u32, r: u32, ) -> Weight { + fn propose(_c: u32, i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `43670` - // Estimated: `3223422` - // Minimum execution time: 2_843_000_000 picoseconds. - Weight::from_parts(3_015_729_300, 3223422) - // Standard Error: 530 - .saturating_add(Weight::from_parts(559, 0).saturating_mul(c.into())) - // Standard Error: 26_838 - .saturating_add(Weight::from_parts(62_860, 0).saturating_mul(r.into())) - .saturating_add(T::DbWeight::get().reads(203_u64)) - .saturating_add(T::DbWeight::get().writes(202_u64)) + // Measured: `707 + i * (115 ±0)` + // Estimated: `15383 + i * (16033 ±0) + r * (6 ±0)` + // Minimum execution time: 75_000_000 picoseconds. + Weight::from_parts(76_000_000, 15383) + // Standard Error: 76_065 + .saturating_add(Weight::from_parts(10_309_802, 0).saturating_mul(i.into())) + // Standard Error: 76_065 + .saturating_add(Weight::from_parts(6_040_304, 0).saturating_mul(r.into())) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) + .saturating_add(T::DbWeight::get().writes(2_u64)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(i.into()))) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(r.into()))) + .saturating_add(Weight::from_parts(0, 16033).saturating_mul(i.into())) + .saturating_add(Weight::from_parts(0, 6).saturating_mul(r.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) @@ -106,20 +111,23 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. /// The range of component `i` is `[0, 200]`. /// The range of component `r` is `[0, 200]`. - fn propose_high_security(c: u32, i: u32, r: u32, ) -> Weight { + fn propose_high_security(_c: u32, i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `43830` - // Estimated: `3223422` - // Minimum execution time: 2_767_000_000 picoseconds. - Weight::from_parts(2_948_221_272, 3223422) - // Standard Error: 396 - .saturating_add(Weight::from_parts(235, 0).saturating_mul(c.into())) - // Standard Error: 20_043 - .saturating_add(Weight::from_parts(75_978, 0).saturating_mul(i.into())) - // Standard Error: 20_043 - .saturating_add(Weight::from_parts(15_851, 0).saturating_mul(r.into())) - .saturating_add(T::DbWeight::get().reads(203_u64)) - .saturating_add(T::DbWeight::get().writes(202_u64)) + // Measured: `867 + i * (115 ±0)` + // Estimated: `15383 + i * (16033 ±0) + r * (6 ±0)` + // Minimum execution time: 44_000_000 picoseconds. + Weight::from_parts(46_000_000, 15383) + // Standard Error: 84_088 + .saturating_add(Weight::from_parts(10_468_058, 0).saturating_mul(i.into())) + // Standard Error: 84_088 + .saturating_add(Weight::from_parts(6_294_734, 0).saturating_mul(r.into())) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) + .saturating_add(T::DbWeight::get().writes(2_u64)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(i.into()))) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(r.into()))) + .saturating_add(Weight::from_parts(0, 16033).saturating_mul(i.into())) + .saturating_add(Weight::from_parts(0, 6).saturating_mul(r.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:0) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) @@ -131,9 +139,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(13_337_928, 17022) - // Standard Error: 26 - .saturating_add(Weight::from_parts(386, 0).saturating_mul(c.into())) + Weight::from_parts(14_463_384, 17022) + // Standard Error: 57 + .saturating_add(Weight::from_parts(1_305, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -146,10 +154,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 24_000_000 picoseconds. - Weight::from_parts(24_421_125, 17022) - // Standard Error: 46 - .saturating_add(Weight::from_parts(1_073, 0).saturating_mul(c.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(29_597_028, 17022) + // Standard Error: 57 + .saturating_add(Weight::from_parts(470, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -162,7 +170,7 @@ impl WeightInfo for SubstrateWeight { // Measured: `730` // Estimated: `17022` // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + Weight::from_parts(22_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -174,8 +182,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `752` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(25_000_000, 17022) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(24_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -187,16 +195,18 @@ impl WeightInfo for SubstrateWeight { /// The range of component `r` is `[1, 200]`. fn claim_deposits(i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `47918` - // Estimated: `3223422` - // Minimum execution time: 2_697_000_000 picoseconds. - Weight::from_parts(2_809_251_030, 3223422) - // Standard Error: 21_665 - .saturating_add(Weight::from_parts(69_674, 0).saturating_mul(i.into())) - // Standard Error: 21_665 - .saturating_add(Weight::from_parts(35_005, 0).saturating_mul(r.into())) - .saturating_add(T::DbWeight::get().reads(202_u64)) - .saturating_add(T::DbWeight::get().writes(201_u64)) + // Measured: `741 + i * (115 ±0)` + // Estimated: `17022 + i * (16032 ±0)` + // Minimum execution time: 23_000_000 picoseconds. + Weight::from_parts(24_000_000, 17022) + // Standard Error: 121_355 + .saturating_add(Weight::from_parts(6_330_271, 0).saturating_mul(i.into())) + // Standard Error: 121_355 + .saturating_add(Weight::from_parts(3_977_029, 0).saturating_mul(r.into())) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) + .saturating_add(T::DbWeight::get().writes(2_u64)) + .saturating_add(Weight::from_parts(0, 16032).saturating_mul(i.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) @@ -210,8 +220,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 26_000_000 picoseconds. - Weight::from_parts(27_000_000, 17022) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(26_000_000, 17022) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -226,10 +236,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 188_000_000 picoseconds. - Weight::from_parts(119_431_673, 10377) - // Standard Error: 34_938 - .saturating_add(Weight::from_parts(4_822_869, 0).saturating_mul(s.into())) + // Minimum execution time: 186_000_000 picoseconds. + Weight::from_parts(115_597_142, 10377) + // Standard Error: 35_292 + .saturating_add(Weight::from_parts(4_856_016, 0).saturating_mul(s.into())) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -242,18 +252,23 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. /// The range of component `i` is `[0, 200]`. /// The range of component `r` is `[0, 200]`. - fn propose(c: u32, _i: u32, r: u32, ) -> Weight { + fn propose(_c: u32, i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `43670` - // Estimated: `3223422` - // Minimum execution time: 2_843_000_000 picoseconds. - Weight::from_parts(3_015_729_300, 3223422) - // Standard Error: 530 - .saturating_add(Weight::from_parts(559, 0).saturating_mul(c.into())) - // Standard Error: 26_838 - .saturating_add(Weight::from_parts(62_860, 0).saturating_mul(r.into())) - .saturating_add(RocksDbWeight::get().reads(203_u64)) - .saturating_add(RocksDbWeight::get().writes(202_u64)) + // Measured: `707 + i * (115 ±0)` + // Estimated: `15383 + i * (16033 ±0) + r * (6 ±0)` + // Minimum execution time: 75_000_000 picoseconds. + Weight::from_parts(76_000_000, 15383) + // Standard Error: 76_065 + .saturating_add(Weight::from_parts(10_309_802, 0).saturating_mul(i.into())) + // Standard Error: 76_065 + .saturating_add(Weight::from_parts(6_040_304, 0).saturating_mul(r.into())) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(i.into()))) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(r.into()))) + .saturating_add(Weight::from_parts(0, 16033).saturating_mul(i.into())) + .saturating_add(Weight::from_parts(0, 6).saturating_mul(r.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) @@ -264,20 +279,23 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. /// The range of component `i` is `[0, 200]`. /// The range of component `r` is `[0, 200]`. - fn propose_high_security(c: u32, i: u32, r: u32, ) -> Weight { + fn propose_high_security(_c: u32, i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `43830` - // Estimated: `3223422` - // Minimum execution time: 2_767_000_000 picoseconds. - Weight::from_parts(2_948_221_272, 3223422) - // Standard Error: 396 - .saturating_add(Weight::from_parts(235, 0).saturating_mul(c.into())) - // Standard Error: 20_043 - .saturating_add(Weight::from_parts(75_978, 0).saturating_mul(i.into())) - // Standard Error: 20_043 - .saturating_add(Weight::from_parts(15_851, 0).saturating_mul(r.into())) - .saturating_add(RocksDbWeight::get().reads(203_u64)) - .saturating_add(RocksDbWeight::get().writes(202_u64)) + // Measured: `867 + i * (115 ±0)` + // Estimated: `15383 + i * (16033 ±0) + r * (6 ±0)` + // Minimum execution time: 44_000_000 picoseconds. + Weight::from_parts(46_000_000, 15383) + // Standard Error: 84_088 + .saturating_add(Weight::from_parts(10_468_058, 0).saturating_mul(i.into())) + // Standard Error: 84_088 + .saturating_add(Weight::from_parts(6_294_734, 0).saturating_mul(r.into())) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(i.into()))) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(r.into()))) + .saturating_add(Weight::from_parts(0, 16033).saturating_mul(i.into())) + .saturating_add(Weight::from_parts(0, 6).saturating_mul(r.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:0) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) @@ -289,9 +307,9 @@ impl WeightInfo for () { // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(13_337_928, 17022) - // Standard Error: 26 - .saturating_add(Weight::from_parts(386, 0).saturating_mul(c.into())) + Weight::from_parts(14_463_384, 17022) + // Standard Error: 57 + .saturating_add(Weight::from_parts(1_305, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -304,10 +322,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 24_000_000 picoseconds. - Weight::from_parts(24_421_125, 17022) - // Standard Error: 46 - .saturating_add(Weight::from_parts(1_073, 0).saturating_mul(c.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(29_597_028, 17022) + // Standard Error: 57 + .saturating_add(Weight::from_parts(470, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -320,7 +338,7 @@ impl WeightInfo for () { // Measured: `730` // Estimated: `17022` // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + Weight::from_parts(22_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -332,8 +350,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `752` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(25_000_000, 17022) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(24_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -345,16 +363,18 @@ impl WeightInfo for () { /// The range of component `r` is `[1, 200]`. fn claim_deposits(i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `47918` - // Estimated: `3223422` - // Minimum execution time: 2_697_000_000 picoseconds. - Weight::from_parts(2_809_251_030, 3223422) - // Standard Error: 21_665 - .saturating_add(Weight::from_parts(69_674, 0).saturating_mul(i.into())) - // Standard Error: 21_665 - .saturating_add(Weight::from_parts(35_005, 0).saturating_mul(r.into())) - .saturating_add(RocksDbWeight::get().reads(202_u64)) - .saturating_add(RocksDbWeight::get().writes(201_u64)) + // Measured: `741 + i * (115 ±0)` + // Estimated: `17022 + i * (16032 ±0)` + // Minimum execution time: 23_000_000 picoseconds. + Weight::from_parts(24_000_000, 17022) + // Standard Error: 121_355 + .saturating_add(Weight::from_parts(6_330_271, 0).saturating_mul(i.into())) + // Standard Error: 121_355 + .saturating_add(Weight::from_parts(3_977_029, 0).saturating_mul(r.into())) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + .saturating_add(Weight::from_parts(0, 16032).saturating_mul(i.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) @@ -368,8 +388,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 26_000_000 picoseconds. - Weight::from_parts(27_000_000, 17022) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(26_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } From 5daf13849a5aa089c64b976f014e5345342523ce Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 10 Feb 2026 09:44:27 +0800 Subject: [PATCH 10/15] feat: Execute - separated --- pallets/multisig/README.md | 160 +++++++++---------- pallets/multisig/src/benchmarking.rs | 163 ++++--------------- pallets/multisig/src/lib.rs | 168 +++++++++++++------ pallets/multisig/src/tests.rs | 230 ++++++++++++++++++--------- pallets/multisig/src/weights.rs | 196 +++++++++-------------- 5 files changed, 449 insertions(+), 468 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index bc99e271..6bbbc7cf 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -21,13 +21,15 @@ let multisig_addr = Multisig::derive_multisig_address(&[bob, charlie, dave], 2, let call = RuntimeCall::Balances(pallet_balances::Call::transfer { dest: eve, value: 100 }); Multisig::propose(Origin::signed(bob), multisig_addr, call.encode(), expiry_block); -// 3. Charlie approves - transaction executes automatically (2/2 threshold reached) +// 3. Charlie approves (2/2 threshold reached → proposal status becomes Approved) Multisig::approve(Origin::signed(charlie), multisig_addr, proposal_id); -// ✅ Transaction executed! No separate call needed. + +// 4. Any signer executes the approved proposal +Multisig::execute(Origin::signed(charlie), multisig_addr, proposal_id); +// ✅ Transaction executed! Proposal removed from storage, deposit returned to proposer. ``` -**Key Point:** Once the threshold is reached, the transaction is **automatically executed**. -There is no separate `execute()` call exposed to users. +**Key Point:** Approval and execution are **separate**. When the threshold is reached, the proposal status becomes `Approved`; any signer must then call `execute()` to dispatch the call. ## Core Functionality @@ -74,24 +76,10 @@ Creates a new proposal for multisig execution. - Expiry must be in the future (expiry > current_block) - Expiry must not exceed MaxExpiryDuration blocks from now (expiry ≤ current_block + MaxExpiryDuration) -**Auto-Cleanup Before Creation:** -Before creating a new proposal, the system **automatically removes all proposer's expired Active proposals**: -- Only proposer's expired proposals are cleaned (not all proposals) -- Expired proposals are identified (current_block > expiry) -- Deposits are returned to original proposer -- Storage is cleaned up -- Counters are decremented (active_proposals, proposals_per_signer) -- Events are emitted for each removed proposal - -This ensures proposers get their deposits back and free up their quota automatically. - -**Threshold=1 Auto-Execution:** -If the multisig has `threshold=1`, the proposal **executes immediately** after creation: -- Proposer's approval counts as the first (and only required) approval -- Call is dispatched automatically -- Proposal is removed from storage immediately -- Deposit is returned to proposer immediately -- No separate `approve()` call needed +**No auto-cleanup in propose:** The pallet does **not** remove expired proposals when creating a new one. To free slots and recover deposits from expired proposals, the proposer must call `claim_deposits()` or any signer can call `remove_expired()` for individual proposals. + +**Threshold=1 behaviour:** +If the multisig has `threshold=1`, the proposal becomes **Approved** immediately after creation (proposer counts as the only required approval). The proposer (or any signer) must then call `execute()` to dispatch the call and remove the proposal. **Economic Costs:** - **ProposalFee**: Non-refundable fee (spam prevention, scaled by signer count) → burned @@ -100,8 +88,7 @@ If the multisig has `threshold=1`, the proposal **executes immediately** after c **Important:** Fee is ALWAYS paid, even if proposal expires or is cancelled. Only deposit is refundable. ### 3. Approve Transaction -Adds caller's approval to an existing proposal. **If this approval brings the total approvals -to or above the threshold, the transaction will be automatically executed and immediately removed from storage.** +Adds caller's approval to an existing proposal. **If this approval brings the total approvals to or above the threshold, the proposal status becomes `Approved`**; the call is **not** executed here—use `execute()` for that. **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) @@ -109,20 +96,16 @@ to or above the threshold, the transaction will be automatically executed and im **Validation:** - Caller must be a signer -- Proposal must exist +- Proposal must exist and be Active - Proposal must not be expired (current_block ≤ expiry) - Caller must not have already approved -**Auto-Execution:** -When approval count reaches the threshold: -- Encoded call is executed as multisig_address origin -- Proposal **immediately removed** from storage -- ProposalDeposit **immediately returned** to proposer -- TransactionExecuted event emitted with execution result - -**Economic Costs:** None (deposit immediately returned on execution) +**When threshold is reached:** +- Proposal status is set to `Approved` +- `ProposalReadyToExecute` event is emitted +- Any signer can then call `execute()` to dispatch the call -**Note:** `approve()` does NOT perform auto-cleanup of expired proposals (removed for predictable gas costs). +**Economic Costs:** None (deposit is returned when the proposal is executed or cancelled). ### 4. Cancel Transaction Cancels a proposal and immediately removes it from storage (proposer only). @@ -133,7 +116,7 @@ Cancels a proposal and immediately removes it from storage (proposer only). **Validation:** - Caller must be the proposer -- Proposal must exist and be Active +- Proposal must exist and be **Active or Approved** (both can be cancelled) **Economic Effects:** - Proposal **immediately removed** from storage @@ -142,14 +125,30 @@ Cancels a proposal and immediately removes it from storage (proposer only). **Economic Costs:** None (deposit immediately returned) -**Note:** -- ProposalFee is NOT refunded - it was burned at proposal creation. -- `cancel()` does NOT perform auto-cleanup of expired proposals (removed for predictable gas costs). +**Note:** ProposalFee is NOT refunded (it was burned at proposal creation). -### 5. Remove Expired -Manually removes expired proposals from storage. Only signers can call this. +### 5. Execute Transaction +Dispatches an **Approved** proposal. Can be called by any signer of the multisig once the approval threshold has been reached. -**Important:** This is rarely needed because proposer's expired proposals are automatically cleaned up when that proposer calls `propose()` or `claim_deposits()`. +**Required Parameters:** +- `multisig_address: AccountId` - Target multisig (REQUIRED) +- `proposal_id: u32` - ID (nonce) of the proposal to execute (REQUIRED) + +**Validation:** +- Caller must be a signer +- Proposal must exist and have status **Approved** +- Proposal must not be expired (current_block ≤ expiry) + +**Effects:** +- Call is decoded and dispatched with multisig_address as origin +- Proposal is removed from storage +- ProposalDeposit is returned to the proposer +- `ProposalExecuted` event is emitted + +**Economic Costs:** Weight depends on call size (charged upfront for MaxCallSize, refunded for actual size). + +### 6. Remove Expired +Manually removes a single expired **Active** proposal from storage. Only signers can call this. Deposit is returned to the original proposer. **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) @@ -160,7 +159,7 @@ Manually removes expired proposals from storage. Only signers can call this. - Proposal must exist and be Active - Must be expired (current_block > expiry) -**Note:** Executed/Cancelled proposals are automatically removed immediately, so this only applies to Active+Expired proposals. +**Note:** Executed/Cancelled proposals are removed immediately when executed/cancelled. This extrinsic only applies to **Active** proposals that are past expiry. **Economic Effects:** - ProposalDeposit returned to **original proposer** (not caller) @@ -169,9 +168,7 @@ Manually removes expired proposals from storage. Only signers can call this. **Economic Costs:** None (deposit always returned to proposer) -**Auto-Cleanup:** When a proposer calls `propose()`, all their expired proposals are automatically removed. This function is useful for cleaning up proposals from inactive proposers. - -### 6. Claim Deposits +### 7. Claim Deposits Batch cleanup operation to recover all caller's expired proposal deposits. **Required Parameters:** @@ -193,12 +190,11 @@ Batch cleanup operation to recover all caller's expired proposal deposits. - Counters decremented (active_proposals, proposals_per_signer) **Economic Costs:** -- Gas cost proportional to total proposals in storage (iteration cost) -- Dynamic weight refund based on actual proposals cleaned +- Gas cost proportional to proposals iterated and cleaned (dynamic weight; charged upfront for worst-case, refunded for actual work) -**Note:** Same functionality as the auto-cleanup in `propose()`, but caller can trigger it manually without creating a new proposal. +**Note:** This is the main way to clean up a proposer's expired proposals and free per-signer quota (there is no auto-cleanup in `propose()`). -### 7. Approve Dissolve +### 8. Approve Dissolve Approve dissolving a multisig account. Requires threshold approvals to complete. **Required Parameters:** @@ -273,22 +269,16 @@ matches!(call, - **MultisigDeposit**: - Reserved on multisig creation - - **Burned** when multisig dissolved (via `approve_dissolve`) + - **Returned to creator** when multisig is dissolved (via `approve_dissolve` after threshold approvals) - Locked until no proposals exist and balance is zero - Opportunity cost incentivizes cleanup - - **NOT refundable** (acts as permanent storage bond) - **ProposalDeposit**: - Reserved on proposal creation - **Refundable** - returned in following scenarios: - - **Auto-Returned Immediately:** - - When proposal executed (threshold reached) - - When proposal cancelled (proposer cancels) - - **Auto-Cleanup:** Proposer's expired proposals are automatically removed when proposer calls `propose()` - - Only proposer's proposals are cleaned (not all) - - Deposits returned to proposer - - Frees up proposer's quota automatically - - **Manual Cleanup:** For inactive proposers via `remove_expired()` or `claim_deposits()` + - **When proposal is executed:** Any signer calls `execute()` on an Approved proposal → deposit returned to proposer + - **When proposal is cancelled:** Proposer calls `cancel()` (Active or Approved) → deposit returned to proposer + - **Expired proposals:** No auto-cleanup in `propose()`. Proposer recovers deposits via `claim_deposits()`; any signer can remove a single expired proposal via `remove_expired()` (deposit → proposer) ### Storage Limits & Configuration **Purpose:** Prevent unbounded storage growth and resource exhaustion @@ -296,10 +286,9 @@ matches!(call, - **MaxSigners**: Maximum signers per multisig - Trade-off: Higher → more flexible governance, more computation per approval -- **MaxTotalProposalsInStorage**: Maximum total proposals (Active + Executed + Cancelled) +- **MaxTotalProposalsInStorage**: Maximum total proposals (Active + Approved; Executed/Cancelled are removed immediately) - Trade-off: Higher → more flexible, more storage risk - - Forces periodic cleanup to continue operating - - **Auto-cleanup**: Expired proposals are automatically removed when new proposals are created + - Forces periodic cleanup to continue operating (via `claim_deposits()` or `remove_expired()`) - **Per-Signer Limit**: Each signer gets `MaxTotalProposalsInStorage / signers_count` quota - Prevents single signer from monopolizing storage (filibuster protection) - Fair allocation ensures all signers can participate @@ -343,11 +332,17 @@ ProposalData { expiry: BlockNumber, // Deadline for approvals approvals: BoundedVec, // List of signers who approved deposit: Balance, // Reserved deposit (refundable) - status: ProposalStatus, // Active only (Executed/Cancelled are removed immediately) + status: ProposalStatus, // Active | Approved (Executed/Cancelled are removed immediately) +} + +enum ProposalStatus { + Active, // Collecting approvals + Approved, // Threshold reached; any signer can call execute() + // Executed and Cancelled are not stored — proposal is removed immediately } ``` -**Important:** Only **Active** proposals are stored. Executed and Cancelled proposals are **immediately removed** from storage and their deposits are returned. Historical data is available through events (see Historical Data section below). +**Important:** Only **Active** and **Approved** proposals are stored. When a proposal is executed or cancelled, it is **immediately removed** from storage and the deposit is returned. Historical data is available through events (see Historical Data section below). ### DissolveApprovals: Map> Tracks which signers have approved dissolving each multisig. @@ -360,6 +355,7 @@ Tracks which signers have approved dissolving each multisig. - `MultisigCreated { creator, multisig_address, signers, threshold, nonce }` - `ProposalCreated { multisig_address, proposer, proposal_id }` - `ProposalApproved { multisig_address, approver, proposal_id, approvals_count }` +- `ProposalReadyToExecute { multisig_address, proposal_id, approvals_count }` — emitted when threshold is reached (approve or propose with threshold=1); proposal is Approved until someone calls `execute()` - `ProposalExecuted { multisig_address, proposal_id, proposer, call, approvers, result }` - `ProposalCancelled { multisig_address, proposer, proposal_id }` - `ProposalRemoved { multisig_address, proposal_id, proposer, removed_by }` @@ -390,7 +386,8 @@ Tracks which signers have approved dissolving each multisig. - `TooManyProposalsInStorage` - Multisig has MaxTotalProposalsInStorage total proposals (cleanup required to create new) - `TooManyProposalsPerSigner` - Caller has reached their per-signer proposal limit (`MaxTotalProposalsInStorage / signers_count`) - `ProposalNotExpired` - Proposal not yet expired (for remove_expired) -- `ProposalNotActive` - Proposal is not active (already executed or cancelled) +- `ProposalNotActive` - Proposal is not active or approved (already executed or cancelled) +- `ProposalNotApproved` - Proposal is not in Approved status (for `execute()`) - `ProposalsExist` - Cannot dissolve multisig while proposals exist - `MultisigAccountNotZero` - Cannot dissolve multisig with non-zero balance @@ -484,12 +481,12 @@ This event structure is optimized for indexing by SubSquid and similar indexers: - Deposits (refundable) prevent storage bloat - MaxTotalProposalsInStorage caps total storage per multisig - Per-signer limits prevent single signer from monopolizing storage (filibuster protection) -- Auto-cleanup of expired proposals reduces storage pressure +- Explicit cleanup (claim_deposits, remove_expired) keeps storage under control ### Storage Cleanup -- Auto-cleanup in `propose()`: proposer's expired proposals removed automatically -- Manual cleanup via `remove_expired()`: any signer can clean any expired proposal -- Batch cleanup via `claim_deposits()`: proposer recovers all their expired deposits at once +- No auto-cleanup in `propose()` (predictable weight; proposer must free slots via cleanup) +- Manual cleanup via `remove_expired()`: any signer can remove a single expired Active proposal (deposit → proposer) +- Batch cleanup via `claim_deposits()`: proposer recovers all their expired proposal deposits at once and frees per-signer quota ### Economic Attacks - **Multisig Spam:** Costs MultisigFee (burned, reduces supply) @@ -523,7 +520,7 @@ impl pallet_multisig::Config for Runtime { // Storage limits (prevent unbounded growth) type MaxSigners = ConstU32<100>; // Max complexity - type MaxTotalProposalsInStorage = ConstU32<200>; // Total storage cap (auto-cleanup on propose) + type MaxTotalProposalsInStorage = ConstU32<200>; // Total storage cap (cleanup via claim_deposits/remove_expired) type MaxCallSize = ConstU32<10240>; // Per-proposal storage limit type MaxExpiryDuration = ConstU32<100_800>; // Max proposal lifetime (~2 weeks @ 12s) @@ -681,27 +678,14 @@ High-security multisigs have higher costs due to call validation: Normal multisigs automatically get refunded for unused high-security overhead. **Weight calculation:** -- `propose()` charges upfront for worst-case: - - `propose_high_security(call.len(), MaxTotalProposalsInStorage, MaxTotalProposalsInStorage.saturating_div(2))` - - Second parameter (`i`): worst-case proposals iterated (MaxTotal) - - Third parameter (`r`): worst-case proposals removed/cleaned (MaxTotal/2, based on 2-signer minimum) -- Actual weight based on: - - Call size (actual, not worst-case) - - Proposals actually iterated during cleanup (`i`) - - Proposals actually removed/cleaned (`r`) -- If multisig is NOT HS, refunds decode overhead based on actual path taken -- If multisig IS HS, charges correctly for decode cost (scales with call size) -- Auto-cleanup returns both iteration count AND cleaned count for accurate weight calculation +- `propose()` charges upfront for worst-case high-security path: `propose_high_security(call.len())`. Actual weight refunded based on path: `propose(call_size)` for normal multisig, `propose_high_security(call_size)` for HS. No cleanup in propose (no iteration/cleanup parameters). +- `execute()` charges upfront for `execute(MaxCallSize)`; actual weight refunded as `execute(actual_call_size)`. +- `claim_deposits()` charges upfront for worst-case iteration and cleanup; actual weight based on proposals iterated and cleaned (dynamic refund). **Security notes:** - Call size is validated BEFORE decode to prevent DoS via oversized payloads -- Weight formula includes O(call_size) component for decode to prevent underpayment -- **Separate charging for iteration cost (reads) vs cleanup cost (writes)**: - - `i` parameter: proposals iterated (O(N) read cost) - - `r` parameter: proposals removed (O(M) write cost, where M ≤ N) -- No refund for the iteration that actually happened (prevents undercharging attack) -- Single-pass optimization: cleanup counts proposals during iteration (no extra pass needed) -- Benchmarks must be regenerated to capture accurate decode costs +- Weight formula includes O(call_size) component for decode (HS path) to prevent underpayment +- Benchmarks must be regenerated after logic changes (see README / MULTISIG_REQ benchmarking section) See `MULTISIG_REQ.md` for detailed cost breakdown and benchmarking instructions. diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 56e83485..0798c1f2 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -67,28 +67,12 @@ mod benchmarks { Ok(()) } - /// Benchmark `propose` extrinsic. - /// Parameters: c = call size, i = iterated proposals, r = removed (cleaned) proposals + /// Benchmark `propose` extrinsic (non-HS path). + /// Parameter: c = call size #[benchmark] fn propose( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, - i: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, - r: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, ) -> Result<(), BenchmarkError> { - // Can't clean more proposals than we iterate - let cleaned_target = (r as u32).min(i); - // Total proposals = iterated (maps directly to iteration parameter) - // Edge case: when i == Max and cleaned_target == 0, all proposals remain - // after cleanup. propose() checks `total_in_storage < Max`, so cap to Max - 1. - // In runtime this combination results in TooManyProposalsInStorage error, - // but we still need the data point for accurate regression at nearby values. - let total_proposals = if i == T::MaxTotalProposalsInStorage::get() && cleaned_target == 0 { - T::MaxTotalProposalsInStorage::get() - 1 - } else { - i - }; - - // Setup: Create a multisig with 3 signers (standard test case) let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); @@ -101,52 +85,22 @@ mod benchmarks { let threshold = 2u32; signers.sort(); - // Create multisig directly in storage + // Create multisig directly in storage (empty, no existing proposals) let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { creator: caller.clone(), signers: bounded_signers, threshold, - proposal_nonce: total_proposals, + proposal_nonce: 0, deposit: T::MultisigDeposit::get(), - active_proposals: total_proposals, + active_proposals: 0, proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); - // Build proposal template once - only expiry varies per proposal - let template_call: BoundedCallOf = { - let system_call = frame_system::Call::::remark { remark: vec![0u8; 10] }; - ::RuntimeCall::from(system_call).encode().try_into().unwrap() - }; - let template_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - // Insert proposals: first `cleaned_target` are expired, rest are non-expired. - // This separates iteration cost (read all total_proposals) from cleanup cost - // (delete cleaned_target). - let expired_block = 10u32.into(); - let future_block = 999999u32.into(); - for idx in 0..total_proposals { - let expiry = if idx < cleaned_target { expired_block } else { future_block }; - Proposals::::insert( - &multisig_address, - idx, - ProposalDataOf:: { - proposer: caller.clone(), - call: template_call.clone(), - expiry, - approvals: template_approvals.clone(), - deposit: 10u32.into(), - status: ProposalStatus::Active, - }, - ); - } - - // Move past expired_block but before future_block frame_system::Pallet::::set_block_number(100u32.into()); - // Create a new proposal (will auto-cleanup expired proposals only) let new_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; let encoded_call = ::RuntimeCall::from(new_call).encode(); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); @@ -154,39 +108,23 @@ mod benchmarks { #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); - // Verify: non-expired proposals remain + 1 new proposal + // Verify proposal was created let multisig = Multisigs::::get(&multisig_address).unwrap(); - let expected_active = (total_proposals - cleaned_target) + 1; - assert_eq!(multisig.active_proposals, expected_active); + assert_eq!(multisig.active_proposals, 1); Ok(()) } /// Benchmark `propose` for high-security multisigs (includes decode + whitelist check). - /// Parameters: c = call size, i = iterated proposals, r = removed (cleaned) proposals + /// More expensive than normal propose due to: + /// 1. is_high_security() check (1 DB read from ReversibleTransfers::HighSecurityAccounts) + /// 2. RuntimeCall decode (O(c) overhead - scales with call size) + /// 3. is_whitelisted() pattern matching + /// Parameter: c = call size #[benchmark] fn propose_high_security( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, - i: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, - r: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, ) -> Result<(), BenchmarkError> { - // Can't clean more proposals than we iterate - let cleaned_target = (r as u32).min(i); - // Total proposals = i (maps directly to iteration parameter) - // Edge case: when i == Max and cleaned_target == 0, cap to Max - 1 - // (same reasoning as propose) - let total_proposals = if i == T::MaxTotalProposalsInStorage::get() && cleaned_target == 0 { - T::MaxTotalProposalsInStorage::get() - 1 - } else { - i - }; - - // More expensive than normal propose due to: - // 1. is_high_security() check (1 DB read from ReversibleTransfers::HighSecurityAccounts) - // 2. RuntimeCall decode (O(c) overhead - scales with call size) - // 3. is_whitelisted() pattern matching - - // Setup: Create a high-security multisig with 3 signers (standard test case) let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); @@ -199,22 +137,21 @@ mod benchmarks { let threshold = 2u32; signers.sort(); - // Create multisig directly in storage + // Create multisig directly in storage (empty, no existing proposals) let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { creator: caller.clone(), signers: bounded_signers, threshold, - proposal_nonce: total_proposals, + proposal_nonce: 0, deposit: T::MultisigDeposit::get(), - active_proposals: total_proposals, + active_proposals: 0, proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); - // IMPORTANT: Set this multisig as high-security for benchmarking - // This ensures we measure the actual HS code path + // Set this multisig as high-security for benchmarking #[cfg(feature = "runtime-benchmarks")] { use pallet_reversible_transfers::{ @@ -226,59 +163,23 @@ mod benchmarks { interceptor: multisig_address.clone(), delay: BlockNumberOrTimestamp::BlockNumber(100u32.into()), }; - // Use helper that accepts T: pallet_reversible_transfers::Config insert_hs_account_for_benchmark::(multisig_address.clone(), hs_data); } - // Build proposal template once - only expiry varies per proposal - let template_call: BoundedCallOf = { - let system_call = frame_system::Call::::remark { remark: vec![0u8; 10] }; - ::RuntimeCall::from(system_call).encode().try_into().unwrap() - }; - let template_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - // Insert proposals: first `cleaned_target` are expired, rest are non-expired. - // This separates iteration cost (read all total_proposals) from cleanup cost - // (delete cleaned_target). - let expired_block = 10u32.into(); - let future_block = 999999u32.into(); - for idx in 0..total_proposals { - let expiry = if idx < cleaned_target { expired_block } else { future_block }; - Proposals::::insert( - &multisig_address, - idx, - ProposalDataOf:: { - proposer: caller.clone(), - call: template_call.clone(), - expiry, - approvals: template_approvals.clone(), - deposit: 10u32.into(), - status: ProposalStatus::Active, - }, - ); - } - - // Move past expired_block but before future_block frame_system::Pallet::::set_block_number(100u32.into()); - // Create a whitelisted call for HS multisig - // Using system::remark with variable size to measure decode cost O(c) + // Whitelisted call with variable size to measure decode cost O(c) // NOTE: system::remark is whitelisted ONLY in runtime-benchmarks mode let new_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; let encoded_call = ::RuntimeCall::from(new_call).encode(); - - // Verify we're testing with actual variable size - assert!(encoded_call.len() >= c as usize, "Call size should scale with parameter c"); - let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); #[extrinsic_call] propose(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); - // Verify: non-expired proposals remain + 1 new proposal + // Verify proposal was created let multisig = Multisigs::::get(&multisig_address).unwrap(); - let expected_active = (total_proposals - cleaned_target) + 1; - assert_eq!(multisig.active_proposals, expected_active); + assert_eq!(multisig.active_proposals, 1); Ok(()) } @@ -359,10 +260,10 @@ mod benchmarks { Ok(()) } - /// Benchmark `approve` when it triggers auto-execution (threshold reached). + /// Benchmark `execute` extrinsic (dispatches an Approved proposal). /// Parameter: c = call size #[benchmark] - fn approve_and_execute( + fn execute( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { let caller: T::AccountId = whitelisted_caller(); @@ -375,34 +276,29 @@ mod benchmarks { let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; let threshold = 2u32; - - // Sort signers to match create_multisig behavior signers.sort(); - // Directly insert multisig into storage let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let multisig_data = MultisigDataOf:: { creator: caller.clone(), signers: bounded_signers, threshold, - proposal_nonce: 1, // We'll insert proposal with id 0 + proposal_nonce: 1, deposit: T::MultisigDeposit::get(), active_proposals: 1, proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); - // Directly insert proposal with 1 approval (caller already approved) - // signer2 will approve and trigger execution - // Create a remark call where the remark itself is c bytes + // Insert an Approved proposal (threshold already reached) let system_call = frame_system::Call::::remark { remark: vec![1u8; c as usize] }; let call = ::RuntimeCall::from(system_call); let encoded_call = call.encode(); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); - let bounded_call: BoundedCallOf = encoded_call.clone().try_into().unwrap(); - // Only 1 approval so far - let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); + let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); + let bounded_approvals: BoundedApprovalsOf = + vec![caller.clone(), signer1.clone()].try_into().unwrap(); let proposal_data = ProposalDataOf:: { proposer: caller.clone(), @@ -410,17 +306,16 @@ mod benchmarks { expiry, approvals: bounded_approvals, deposit: 10u32.into(), - status: ProposalStatus::Active, + status: ProposalStatus::Approved, }; let proposal_id = 0u32; Proposals::::insert(&multisig_address, proposal_id, proposal_data); - // signer2 approves, reaching threshold (2/2), triggering auto-execution #[extrinsic_call] - approve(RawOrigin::Signed(signer2.clone()), multisig_address.clone(), proposal_id); + _(RawOrigin::Signed(signer2.clone()), multisig_address.clone(), proposal_id); - // Verify proposal was removed from storage (auto-deleted after execution) + // Verify proposal was removed from storage after execution assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); Ok(()) diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 995b49c3..b0836589 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -88,6 +88,8 @@ impl< pub enum ProposalStatus { /// Proposal is active and awaiting approvals Active, + /// Proposal has reached threshold and is ready to execute + Approved, /// Proposal was executed successfully Executed, /// Proposal was cancelled by proposer @@ -290,6 +292,12 @@ pub mod pallet { proposal_id: u32, approvals_count: u32, }, + /// A proposal has reached threshold and is ready to execute + ProposalReadyToExecute { + multisig_address: T::AccountId, + proposal_id: u32, + approvals_count: u32, + }, /// A proposal has been executed /// Contains all data needed for indexing by SubSquid ProposalExecuted { @@ -383,6 +391,8 @@ pub mod pallet { ProposalNotExpired, /// Proposal is not active (already executed or cancelled) ProposalNotActive, + /// Proposal has not been approved yet (threshold not reached) + ProposalNotApproved, /// Cannot dissolve multisig with existing proposals (clear them first) ProposalsExist, /// Multisig account must have zero balance before dissolution @@ -504,14 +514,10 @@ pub mod pallet { /// /// **For threshold=1:** If the multisig threshold is 1, the proposal executes immediately. /// - /// **Weight:** Charged based on whether multisig is high-security or not. - /// High-security multisigs incur additional cost for decode + whitelist check. + /// **Weight:** Charged upfront for worst-case (high-security path with decode). + /// Refunded to actual cost on success based on whether HS path was taken. #[pallet::call_index(1)] - #[pallet::weight(::WeightInfo::propose_high_security( - call.len() as u32, - T::MaxTotalProposalsInStorage::get(), // Worst-case iterated - T::MaxTotalProposalsInStorage::get().saturating_div(2) // Worst-case cleaned (MaxTotal / 2 signers) - ))] + #[pallet::weight(::WeightInfo::propose_high_security(call.len() as u32))] #[allow(clippy::useless_conversion)] pub fn propose( origin: OriginFor, @@ -565,26 +571,13 @@ pub mod pallet { } } - // Auto-cleanup ALL proposer's expired proposals before creating new one - // This is the primary cleanup mechanism for active multisigs - // Returns: (cleaned_count, total_proposals_iterated) - // - cleaned_count: proposals removed (O(M) write cost) - // - total_proposals_iterated: proposals iterated (O(N) read cost, where N >= M) - let (cleaned, total_proposals_iterated) = - Self::cleanup_proposer_expired(&multisig_address, &proposer, &proposer); - - // Reload multisig data after potential cleanup - let multisig_data = - Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; - let current_block = frame_system::Pallet::::block_number(); // Get signers count (used for multiple checks below) let signers_count = multisig_data.signers.len() as u32; - // Check total proposals in storage limit (Active + Executed + Cancelled) - // This incentivizes cleanup and prevents unbounded storage growth - // NOTE: After cleanup, so this is the NEW count (post-cleanup) + // Check total proposals in storage limit + // Users must call claim_deposits() or remove_expired() to free space let total_proposals_in_storage = Proposals::::iter_prefix(&multisig_address).count() as u32; ensure!( @@ -690,27 +683,23 @@ pub mod pallet { // Check if threshold is reached immediately (threshold=1 case) // Proposer is already counted as first approval if 1 >= multisig_data.threshold { - // Threshold reached - execute immediately - // Need to get proposal again since we inserted it - let proposal = Proposals::::get(&multisig_address, proposal_id) - .ok_or(Error::::ProposalNotFound)?; - Self::do_execute(multisig_address, proposal_id, proposal)?; + Proposals::::mutate(&multisig_address, proposal_id, |maybe_proposal| { + if let Some(ref mut p) = maybe_proposal { + p.status = ProposalStatus::Approved; + } + }); + Self::deposit_event(Event::ProposalReadyToExecute { + multisig_address: multisig_address.clone(), + proposal_id, + approvals_count: 1, + }); } - // Calculate actual weight based on call size, proposals iterated, and cleaned - // Accurate charging based on actual work performed: - // - total_proposals_iterated: O(N) read cost - // - cleaned: O(M) write cost (where M <= N) + // Refund weight: HS path was charged upfront, refund if non-HS let actual_weight = if is_high_security { - // Used high-security path (decode + whitelist check) - ::WeightInfo::propose_high_security( - call_size, - total_proposals_iterated, - cleaned, - ) + ::WeightInfo::propose_high_security(call_size) } else { - // Used normal path (no decode overhead) - ::WeightInfo::propose(call_size, total_proposals_iterated, cleaned) + ::WeightInfo::propose(call_size) }; Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) @@ -719,14 +708,13 @@ pub mod pallet { /// Approve a proposed transaction /// /// If this approval brings the total approvals to or above the threshold, - /// the transaction will be automatically executed. + /// the proposal status changes to `Approved` and can be executed via `execute()`. /// /// Parameters: /// - `multisig_address`: The multisig account /// - `proposal_id`: ID (nonce) of the proposal to approve /// /// Weight: Charges for MAX call size, refunds based on actual - /// NOTE: approve() does NOT do auto-cleanup (removed for predictable gas costs) #[pallet::call_index(2)] #[pallet::weight(::WeightInfo::approve(T::MaxCallSize::get()))] #[allow(clippy::useless_conversion)] @@ -786,6 +774,14 @@ pub mod pallet { let approvals_count = proposal.approvals.len() as u32; + // Check if threshold is reached - if so, mark as Approved + if approvals_count >= multisig_data.threshold { + proposal.status = ProposalStatus::Approved; + } + + // Save proposal + Proposals::::insert(&multisig_address, proposal_id, &proposal); + // Emit approval event Self::deposit_event(Event::ProposalApproved { multisig_address: multisig_address.clone(), @@ -794,13 +790,13 @@ pub mod pallet { approvals_count, }); - // Check if threshold is reached - if so, execute immediately - if approvals_count >= multisig_data.threshold { - // Execute the transaction - Self::do_execute(multisig_address, proposal_id, proposal)?; - } else { - // Not ready yet, just save the proposal - Proposals::::insert(&multisig_address, proposal_id, proposal); + // Emit ready-to-execute event if threshold just reached + if proposal.status == ProposalStatus::Approved { + Self::deposit_event(Event::ProposalReadyToExecute { + multisig_address, + proposal_id, + approvals_count, + }); } // Return actual weight (refund overpayment) @@ -839,8 +835,10 @@ pub mod pallet { return Self::err_with_weight(Error::::NotProposer, 1); } - // Check if proposal is still active (1 read already performed) - if proposal.status != ProposalStatus::Active { + // Check if proposal is cancellable (Active or Approved) + if proposal.status != ProposalStatus::Active && + proposal.status != ProposalStatus::Approved + { return Self::err_with_weight(Error::::ProposalNotActive, 1); } @@ -962,6 +960,76 @@ pub mod pallet { Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } + /// Execute an approved proposal + /// + /// Can be called by any signer of the multisig once the proposal has reached + /// the approval threshold (status = Approved). The proposal must not be expired. + /// + /// On execution: + /// - The call is decoded and dispatched as the multisig account + /// - Proposal is removed from storage + /// - Deposit is returned to the proposer + /// + /// Parameters: + /// - `multisig_address`: The multisig account + /// - `proposal_id`: ID (nonce) of the proposal to execute + #[pallet::call_index(7)] + #[pallet::weight(::WeightInfo::execute(T::MaxCallSize::get()))] + #[allow(clippy::useless_conversion)] + pub fn execute( + origin: OriginFor, + multisig_address: T::AccountId, + proposal_id: u32, + ) -> DispatchResultWithPostInfo { + let executor = ensure_signed(origin)?; + + // Check if executor is a signer (1 read: Multisigs) + let multisig_data = Multisigs::::get(&multisig_address).ok_or_else(|| { + DispatchErrorWithPostInfo { + post_info: PostDispatchInfo { + actual_weight: Some(T::DbWeight::get().reads(1)), + pays_fee: Pays::Yes, + }, + error: Error::::MultisigNotFound.into(), + } + })?; + if !multisig_data.signers.contains(&executor) { + return Self::err_with_weight(Error::::NotASigner, 1); + } + + // Get proposal (2 reads: Multisigs + Proposals) + let proposal = + Proposals::::get(&multisig_address, proposal_id).ok_or_else(|| { + DispatchErrorWithPostInfo { + post_info: PostDispatchInfo { + actual_weight: Some(T::DbWeight::get().reads(2)), + pays_fee: Pays::Yes, + }, + error: Error::::ProposalNotFound.into(), + } + })?; + + // Must be Approved status + if proposal.status != ProposalStatus::Approved { + return Self::err_with_weight(Error::::ProposalNotApproved, 2); + } + + // Must not be expired + let current_block = frame_system::Pallet::::block_number(); + if current_block > proposal.expiry { + return Self::err_with_weight(Error::::ProposalExpired, 2); + } + + // Calculate actual weight based on real call size + let actual_call_size = proposal.call.len() as u32; + let actual_weight = ::WeightInfo::execute(actual_call_size); + + // Execute the proposal + Self::do_execute(multisig_address, proposal_id, proposal)?; + + Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) + } + /// Approve dissolving a multisig account /// /// Signers call this to approve dissolving the multisig. diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index c0809a00..444661eb 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -5,6 +5,7 @@ use codec::Encode; use frame_support::{assert_noop, assert_ok, traits::fungible::Mutate}; use qp_high_security::HighSecurityInspector; use sp_core::crypto::AccountId32; +use sp_runtime::DispatchError; /// Mock implementation for HighSecurityInspector pub struct MockHighSecurity; @@ -62,6 +63,18 @@ fn get_last_proposal_id(multisig_address: &AccountId32) -> u32 { multisig.proposal_nonce.saturating_sub(1) } +/// Assert that a DispatchResultWithPostInfo is Err with the expected error variant, +/// ignoring the PostDispatchInfo (actual_weight). +fn assert_err_ignore_postinfo( + result: sp_runtime::DispatchResultWithInfo, + expected: DispatchError, +) { + match result { + Err(err) => assert_eq!(err.error, expected), + Ok(_) => panic!("Expected Err({:?}), got Ok", expected), + } +} + // ==================== MULTISIG CREATION TESTS ==================== #[test] @@ -286,9 +299,9 @@ fn propose_fails_if_not_signer() { // Try to propose as non-signer let call = make_call(vec![1, 2, 3]); - assert_noop!( + assert_err_ignore_postinfo( Multisig::propose(RuntimeOrigin::signed(dave()), multisig_address.clone(), call, 1000), - Error::::NotASigner + Error::::NotASigner.into(), ); }); } @@ -346,7 +359,7 @@ fn approve_works() { } #[test] -fn approve_auto_executes_when_threshold_reached() { +fn approve_sets_approved_when_threshold_reached() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -371,20 +384,44 @@ fn approve_auto_executes_when_threshold_reached() { let proposal_id = get_last_proposal_id(&multisig_address); - // Charlie approves - threshold reached (2/2), auto-executes and removes + // Charlie approves - threshold reached (2/2), status becomes Approved assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address.clone(), proposal_id )); - // Check that proposal was executed and immediately removed from storage + // Proposal should still exist with Approved status + let proposal = crate::Proposals::::get(&multisig_address, proposal_id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Approved); + + // Deposit should still be reserved (not returned until execute) + assert!(Balances::reserved_balance(bob()) > 0); + + // Check ProposalReadyToExecute event + System::assert_has_event( + Event::ProposalReadyToExecute { + multisig_address: multisig_address.clone(), + proposal_id, + approvals_count: 2, + } + .into(), + ); + + // Now any signer can execute + assert_ok!(Multisig::execute( + RuntimeOrigin::signed(charlie()), + multisig_address.clone(), + proposal_id + )); + + // Now proposal is removed assert!(crate::Proposals::::get(&multisig_address, proposal_id).is_none()); - // Deposit should be returned immediately - assert_eq!(Balances::reserved_balance(bob()), 0); // No longer reserved + // Deposit returned + assert_eq!(Balances::reserved_balance(bob()), 0); - // Check event was emitted + // Check execution event System::assert_has_event( Event::ProposalExecuted { multisig_address, @@ -474,17 +511,24 @@ fn cancel_fails_if_already_executed() { let proposal_id = get_last_proposal_id(&multisig_address); - // Approve to execute (auto-executes and removes proposal) + // Approve (reaches threshold → Approved) assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address.clone(), proposal_id )); + // Execute (removes proposal from storage) + assert_ok!(Multisig::execute( + RuntimeOrigin::signed(charlie()), + multisig_address.clone(), + proposal_id + )); + // Try to cancel executed proposal (already removed, so ProposalNotFound) - assert_noop!( + assert_err_ignore_postinfo( Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address.clone(), proposal_id), - Error::::ProposalNotFound + Error::::ProposalNotFound.into(), ); }); } @@ -537,7 +581,7 @@ fn remove_expired_works_after_grace_period() { } #[test] -fn executed_proposals_auto_removed() { +fn executed_proposals_removed_from_storage() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -562,20 +606,27 @@ fn executed_proposals_auto_removed() { let proposal_id = get_last_proposal_id(&multisig_address); - // Execute - should auto-remove proposal and return deposit + // Approve → Approved assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address.clone(), proposal_id )); - // Proposal should be immediately removed + // Execute → removed from storage, deposit returned + assert_ok!(Multisig::execute( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + proposal_id + )); + + // Proposal should be removed assert!(crate::Proposals::::get(&multisig_address, proposal_id).is_none()); - // Deposit should be immediately returned + // Deposit should be returned assert_eq!(Balances::reserved_balance(bob()), 0); - // Trying to remove again should fail (already removed) + // Trying to remove again should fail assert_noop!( Multisig::remove_expired( RuntimeOrigin::signed(charlie()), @@ -806,9 +857,10 @@ fn only_active_proposals_remain_in_storage() { )); let multisig_address = Multisig::derive_multisig_address(&signers, 2, 0); - // Test that only Active proposals remain in storage (Executed/Cancelled auto-removed) + // Test that only Active/Approved proposals remain in storage + // (Executed/Cancelled are removed) - // Bob creates 10, executes 5, cancels 1 - only 4 active remain + // Bob creates 10, approves+executes 5, cancels 1 - only 4 active remain for i in 0..10 { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( @@ -820,11 +872,18 @@ fn only_active_proposals_remain_in_storage() { if i < 5 { let id = get_last_proposal_id(&multisig_address); + // Approve → Approved assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address.clone(), id )); + // Execute → removed + assert_ok!(Multisig::execute( + RuntimeOrigin::signed(charlie()), + multisig_address.clone(), + id + )); } else if i == 5 { let id = get_last_proposal_id(&multisig_address); assert_ok!(Multisig::cancel( @@ -861,7 +920,7 @@ fn only_active_proposals_remain_in_storage() { } #[test] -fn auto_cleanup_allows_new_proposals() { +fn per_signer_limit_blocks_new_proposals_until_cleanup() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -900,7 +959,24 @@ fn auto_cleanup_allows_new_proposals() { // Move past expiry System::set_block_number(101); - // Now Bob can create new - propose() auto-cleans his expired proposals + // propose() no longer auto-cleans, so Bob is still blocked + assert_noop!( + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + make_call(vec![99]), + 200 + ), + Error::::TooManyProposalsPerSigner + ); + + // Bob must explicitly claim deposits to free space + assert_ok!(Multisig::claim_deposits( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + )); + + // Now Bob can create new assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address.clone(), @@ -908,9 +984,9 @@ fn auto_cleanup_allows_new_proposals() { 200 )); - // Verify old proposals were removed (only the new one remains) + // Verify: old expired removed by claim_deposits, plus the new one let count = crate::Proposals::::iter_prefix(&multisig_address).count(); - assert_eq!(count, 1); // Only the new one remains + assert_eq!(count, 1); }); } @@ -1193,7 +1269,7 @@ fn per_signer_proposal_limit_enforced() { } #[test] -fn propose_with_threshold_one_executes_immediately() { +fn propose_with_threshold_one_sets_approved() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -1216,7 +1292,7 @@ fn propose_with_threshold_one_executes_immediately() { let initial_dave_balance = Balances::free_balance(dave()); - // Alice proposes a transfer - should execute immediately since threshold=1 + // Alice proposes a transfer - threshold=1, so immediately Approved let transfer_call = RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { dest: dave(), value: 1000, @@ -1229,28 +1305,37 @@ fn propose_with_threshold_one_executes_immediately() { 100 )); - let proposal_id = 0; // First proposal + let proposal_id = 0; - // Verify the proposal was executed immediately (should NOT exist anymore) - assert!(Proposals::::get(&multisig_address, proposal_id).is_none()); + // Proposal should be Approved (not executed yet) + let proposal = Proposals::::get(&multisig_address, proposal_id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Approved); - // Verify the transfer actually happened - assert_eq!(Balances::free_balance(dave()), initial_dave_balance + 1000); + // Transfer hasn't happened yet + assert_eq!(Balances::free_balance(dave()), initial_dave_balance); - // Verify ProposalExecuted event was emitted + // Check ProposalReadyToExecute event System::assert_has_event( - Event::ProposalExecuted { + Event::ProposalReadyToExecute { multisig_address: multisig_address.clone(), proposal_id, - proposer: alice(), - call: transfer_call.encode(), - approvers: vec![alice()], - result: Ok(()), + approvals_count: 1, } .into(), ); - // Verify deposit was returned to Alice (execution removes proposal) + // Any signer can now execute + assert_ok!(Multisig::execute( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + proposal_id + )); + + // Now the transfer happened + assert_eq!(Balances::free_balance(dave()), initial_dave_balance + 1000); + + // Proposal removed, deposit returned + assert!(Proposals::::get(&multisig_address, proposal_id).is_none()); let alice_reserved = Balances::reserved_balance(alice()); assert_eq!(alice_reserved, 500); // Only MultisigDeposit, no ProposalDeposit }); @@ -1303,23 +1388,35 @@ fn propose_with_threshold_two_waits_for_approval() { // Verify the transfer did NOT happen yet assert_eq!(Balances::free_balance(dave()), initial_dave_balance); - // Bob approves - NOW it should execute (threshold=2 reached) + // Bob approves - threshold=2 reached → Approved assert_ok!(Multisig::approve( RuntimeOrigin::signed(bob()), multisig_address.clone(), proposal_id )); - // Now proposal should be executed and removed - assert!(Proposals::::get(&multisig_address, proposal_id).is_none()); + // Proposal should be Approved but NOT removed + let proposal = Proposals::::get(&multisig_address, proposal_id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Approved); + + // Transfer NOT yet happened + assert_eq!(Balances::free_balance(dave()), initial_dave_balance); + + // Now execute + assert_ok!(Multisig::execute( + RuntimeOrigin::signed(charlie()), + multisig_address.clone(), + proposal_id + )); - // Verify the transfer happened + // Now proposal removed and transfer happened + assert!(Proposals::::get(&multisig_address, proposal_id).is_none()); assert_eq!(Balances::free_balance(dave()), initial_dave_balance + 1000); }); } #[test] -fn auto_cleanup_on_approve_and_cancel() { +fn no_auto_cleanup_on_propose_approve_cancel() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -1359,60 +1456,41 @@ fn auto_cleanup_on_approve_and_cancel() { // Move time forward past first proposal expiry System::set_block_number(101); - // Charlie approves proposal #1 - // IMPORTANT: approve() NO LONGER does auto-cleanup (removed for predictable gas) + // approve() does NOT auto-cleanup assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address.clone(), 1 )); + assert!(Proposals::::get(&multisig_address, 0).is_some()); // expired but still there - // Verify proposal #0 still exists (NOT auto-cleaned by approve()) - assert!(Proposals::::get(&multisig_address, 0).is_some()); - // Proposal #1 still exists (waiting for more approvals) - assert!(Proposals::::get(&multisig_address, 1).is_some()); - - // Alice creates another proposal - // IMPORTANT: propose() DOES auto-cleanup of proposer's expired proposals - // So this will clean proposal #0 (Alice's expired proposal) + // propose() does NOT auto-cleanup either assert_ok!(Multisig::propose( RuntimeOrigin::signed(alice()), multisig_address.clone(), make_call(vec![3]), - 150 // expires at block 150 + 150 )); - - // Verify proposal #0 was auto-cleaned by propose() - assert!(Proposals::::get(&multisig_address, 0).is_none()); - // Proposal #1 still exists + // Proposal #0 still exists - not auto-cleaned + assert!(Proposals::::get(&multisig_address, 0).is_some()); assert!(Proposals::::get(&multisig_address, 1).is_some()); - // Proposal #2 exists (just created) assert!(Proposals::::get(&multisig_address, 2).is_some()); - // Move time forward past proposal #2 expiry + // cancel() does NOT auto-cleanup System::set_block_number(151); - - // Bob cancels his own proposal #1 - // IMPORTANT: cancel() NO LONGER does auto-cleanup (removed for predictable gas) assert_ok!(Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address.clone(), 1)); + assert!(Proposals::::get(&multisig_address, 1).is_none()); // cancelled + assert!(Proposals::::get(&multisig_address, 0).is_some()); // expired, still there + assert!(Proposals::::get(&multisig_address, 2).is_some()); // expired, still there - // Verify proposal #2 still exists (NOT auto-cleaned by cancel()) - assert!(Proposals::::get(&multisig_address, 2).is_some()); - // Proposal #1 was cancelled and removed - assert!(Proposals::::get(&multisig_address, 1).is_none()); - - // Alice creates another proposal - this will clean her expired #2 - assert_ok!(Multisig::propose( + // Only explicit cleanup works: claim_deposits or remove_expired + assert_ok!(Multisig::claim_deposits( RuntimeOrigin::signed(alice()), multisig_address.clone(), - make_call(vec![4]), - 300 )); - - // Now Alice's expired proposal #2 should be cleaned + // Alice's expired proposals (#0, #2) now cleaned + assert!(Proposals::::get(&multisig_address, 0).is_none()); assert!(Proposals::::get(&multisig_address, 2).is_none()); - // Only the new proposal #3 exists - assert!(Proposals::::get(&multisig_address, 3).is_some()); }); } @@ -1443,9 +1521,9 @@ fn high_security_propose_fails_for_non_whitelisted_call() { // Try to propose a non-whitelisted call (remark without "safe") let call = make_call(b"unsafe".to_vec()); - assert_noop!( + assert_err_ignore_postinfo( Multisig::propose(RuntimeOrigin::signed(alice()), multisig_address.clone(), call, 1000), - Error::::CallNotAllowedForHighSecurityMultisig + Error::::CallNotAllowedForHighSecurityMultisig.into(), ); // Try to propose a whitelisted call (remark with "safe") - should work diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index d521c3b5..c13160f8 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-02-09, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-02-10, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -48,10 +48,10 @@ use core::marker::PhantomData; /// Weight functions needed for `pallet_multisig`. pub trait WeightInfo { fn create_multisig(s: u32, ) -> Weight; - fn propose(c: u32, i: u32, r: u32, ) -> Weight; - fn propose_high_security(c: u32, i: u32, r: u32, ) -> Weight; + fn propose(c: u32, ) -> Weight; + fn propose_high_security(c: u32, ) -> Weight; fn approve(c: u32, ) -> Weight; - fn approve_and_execute(c: u32, ) -> Weight; + fn execute(c: u32, ) -> Weight; fn cancel() -> Weight; fn remove_expired() -> Weight; fn claim_deposits(i: u32, r: u32, ) -> Weight; @@ -68,10 +68,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 186_000_000 picoseconds. - Weight::from_parts(115_597_142, 10377) - // Standard Error: 35_292 - .saturating_add(Weight::from_parts(4_856_016, 0).saturating_mul(s.into())) + // Minimum execution time: 177_000_000 picoseconds. + Weight::from_parts(117_547_346, 10377) + // Standard Error: 37_775 + .saturating_add(Weight::from_parts(4_883_267, 0).saturating_mul(s.into())) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -79,55 +79,35 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:201 w:201) + /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `i` is `[0, 200]`. - /// The range of component `r` is `[0, 200]`. - fn propose(_c: u32, i: u32, r: u32, ) -> Weight { + fn propose(_c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `707 + i * (115 ±0)` - // Estimated: `15383 + i * (16033 ±0) + r * (6 ±0)` - // Minimum execution time: 75_000_000 picoseconds. - Weight::from_parts(76_000_000, 15383) - // Standard Error: 76_065 - .saturating_add(Weight::from_parts(10_309_802, 0).saturating_mul(i.into())) - // Standard Error: 76_065 - .saturating_add(Weight::from_parts(6_040_304, 0).saturating_mul(r.into())) + // Measured: `678` + // Estimated: `17022` + // Minimum execution time: 69_000_000 picoseconds. + Weight::from_parts(76_077_943, 17022) .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(i.into()))) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(r.into()))) - .saturating_add(Weight::from_parts(0, 16033).saturating_mul(i.into())) - .saturating_add(Weight::from_parts(0, 6).saturating_mul(r.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:201 w:201) + /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `i` is `[0, 200]`. - /// The range of component `r` is `[0, 200]`. - fn propose_high_security(_c: u32, i: u32, r: u32, ) -> Weight { + fn propose_high_security(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `867 + i * (115 ±0)` - // Estimated: `15383 + i * (16033 ±0) + r * (6 ±0)` - // Minimum execution time: 44_000_000 picoseconds. - Weight::from_parts(46_000_000, 15383) - // Standard Error: 84_088 - .saturating_add(Weight::from_parts(10_468_058, 0).saturating_mul(i.into())) - // Standard Error: 84_088 - .saturating_add(Weight::from_parts(6_294_734, 0).saturating_mul(r.into())) + // Measured: `838` + // Estimated: `17022` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(42_099_445, 17022) + // Standard Error: 76 + .saturating_add(Weight::from_parts(457, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(i.into()))) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(r.into()))) - .saturating_add(Weight::from_parts(0, 16033).saturating_mul(i.into())) - .saturating_add(Weight::from_parts(0, 6).saturating_mul(r.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:0) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) @@ -139,9 +119,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(14_463_384, 17022) - // Standard Error: 57 - .saturating_add(Weight::from_parts(1_305, 0).saturating_mul(c.into())) + Weight::from_parts(13_064_381, 17022) + // Standard Error: 33 + .saturating_add(Weight::from_parts(705, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -150,14 +130,12 @@ impl WeightInfo for SubstrateWeight { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn approve_and_execute(c: u32, ) -> Weight { + fn execute(_c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `722 + c * (1 ±0)` + // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(29_597_028, 17022) - // Standard Error: 57 - .saturating_add(Weight::from_parts(470, 0).saturating_mul(c.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(27_562_800, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -169,8 +147,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `730` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(21_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -182,8 +160,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `752` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(24_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -197,12 +175,12 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `741 + i * (115 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 23_000_000 picoseconds. + // Minimum execution time: 24_000_000 picoseconds. Weight::from_parts(24_000_000, 17022) - // Standard Error: 121_355 - .saturating_add(Weight::from_parts(6_330_271, 0).saturating_mul(i.into())) - // Standard Error: 121_355 - .saturating_add(Weight::from_parts(3_977_029, 0).saturating_mul(r.into())) + // Standard Error: 130_229 + .saturating_add(Weight::from_parts(6_989_300, 0).saturating_mul(i.into())) + // Standard Error: 130_229 + .saturating_add(Weight::from_parts(4_096_261, 0).saturating_mul(r.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -220,8 +198,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) + // Minimum execution time: 27_000_000 picoseconds. + Weight::from_parts(28_000_000, 17022) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -236,10 +214,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 186_000_000 picoseconds. - Weight::from_parts(115_597_142, 10377) - // Standard Error: 35_292 - .saturating_add(Weight::from_parts(4_856_016, 0).saturating_mul(s.into())) + // Minimum execution time: 177_000_000 picoseconds. + Weight::from_parts(117_547_346, 10377) + // Standard Error: 37_775 + .saturating_add(Weight::from_parts(4_883_267, 0).saturating_mul(s.into())) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -247,55 +225,35 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:201 w:201) + /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `i` is `[0, 200]`. - /// The range of component `r` is `[0, 200]`. - fn propose(_c: u32, i: u32, r: u32, ) -> Weight { + fn propose(_c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `707 + i * (115 ±0)` - // Estimated: `15383 + i * (16033 ±0) + r * (6 ±0)` - // Minimum execution time: 75_000_000 picoseconds. - Weight::from_parts(76_000_000, 15383) - // Standard Error: 76_065 - .saturating_add(Weight::from_parts(10_309_802, 0).saturating_mul(i.into())) - // Standard Error: 76_065 - .saturating_add(Weight::from_parts(6_040_304, 0).saturating_mul(r.into())) + // Measured: `678` + // Estimated: `17022` + // Minimum execution time: 69_000_000 picoseconds. + Weight::from_parts(76_077_943, 17022) .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(i.into()))) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(r.into()))) - .saturating_add(Weight::from_parts(0, 16033).saturating_mul(i.into())) - .saturating_add(Weight::from_parts(0, 6).saturating_mul(r.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) /// Proof: `ReversibleTransfers::HighSecurityAccounts` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:201 w:201) + /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - /// The range of component `i` is `[0, 200]`. - /// The range of component `r` is `[0, 200]`. - fn propose_high_security(_c: u32, i: u32, r: u32, ) -> Weight { + fn propose_high_security(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `867 + i * (115 ±0)` - // Estimated: `15383 + i * (16033 ±0) + r * (6 ±0)` - // Minimum execution time: 44_000_000 picoseconds. - Weight::from_parts(46_000_000, 15383) - // Standard Error: 84_088 - .saturating_add(Weight::from_parts(10_468_058, 0).saturating_mul(i.into())) - // Standard Error: 84_088 - .saturating_add(Weight::from_parts(6_294_734, 0).saturating_mul(r.into())) + // Measured: `838` + // Estimated: `17022` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(42_099_445, 17022) + // Standard Error: 76 + .saturating_add(Weight::from_parts(457, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(i.into()))) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(r.into()))) - .saturating_add(Weight::from_parts(0, 16033).saturating_mul(i.into())) - .saturating_add(Weight::from_parts(0, 6).saturating_mul(r.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:0) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) @@ -307,9 +265,9 @@ impl WeightInfo for () { // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(14_463_384, 17022) - // Standard Error: 57 - .saturating_add(Weight::from_parts(1_305, 0).saturating_mul(c.into())) + Weight::from_parts(13_064_381, 17022) + // Standard Error: 33 + .saturating_add(Weight::from_parts(705, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -318,14 +276,12 @@ impl WeightInfo for () { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn approve_and_execute(c: u32, ) -> Weight { + fn execute(_c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `722 + c * (1 ±0)` + // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(29_597_028, 17022) - // Standard Error: 57 - .saturating_add(Weight::from_parts(470, 0).saturating_mul(c.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(27_562_800, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -337,8 +293,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `730` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(21_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -350,8 +306,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `752` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(24_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -365,12 +321,12 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `741 + i * (115 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 23_000_000 picoseconds. + // Minimum execution time: 24_000_000 picoseconds. Weight::from_parts(24_000_000, 17022) - // Standard Error: 121_355 - .saturating_add(Weight::from_parts(6_330_271, 0).saturating_mul(i.into())) - // Standard Error: 121_355 - .saturating_add(Weight::from_parts(3_977_029, 0).saturating_mul(r.into())) + // Standard Error: 130_229 + .saturating_add(Weight::from_parts(6_989_300, 0).saturating_mul(i.into())) + // Standard Error: 130_229 + .saturating_add(Weight::from_parts(4_096_261, 0).saturating_mul(r.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -388,8 +344,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) + // Minimum execution time: 27_000_000 picoseconds. + Weight::from_parts(28_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } From 9175c673064634b50bda3c57ed3fc2c8f13b8a35 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 10 Feb 2026 13:32:19 +0800 Subject: [PATCH 11/15] feat: Multisig - benchamarks refactor --- Cargo.lock | 6 + pallets/multisig/Cargo.toml | 10 + pallets/multisig/src/benchmarking.rs | 532 +++++++++------------------ pallets/multisig/src/mock.rs | 336 +++++++++++++---- pallets/multisig/src/weights.rs | 151 ++++---- 5 files changed, 521 insertions(+), 514 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7b77e3d..a37eba52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7178,9 +7178,15 @@ dependencies = [ "frame-support", "frame-system", "log", + "pallet-assets", + "pallet-assets-holder", "pallet-balances 40.0.1", + "pallet-preimage", + "pallet-recovery", "pallet-reversible-transfers", + "pallet-scheduler", "pallet-timestamp", + "pallet-utility", "parity-scale-codec", "qp-high-security", "qp-scheduler", diff --git a/pallets/multisig/Cargo.toml b/pallets/multisig/Cargo.toml index f89429d8..0fd05da0 100644 --- a/pallets/multisig/Cargo.toml +++ b/pallets/multisig/Cargo.toml @@ -29,10 +29,20 @@ sp-runtime.workspace = true [dev-dependencies] frame-support = { workspace = true, features = ["experimental"], default-features = true } +frame-system = { workspace = true, default-features = true } +pallet-assets = { workspace = true, default-features = true } +pallet-assets-holder = { workspace = true, default-features = true } pallet-balances = { workspace = true, features = ["std"] } +pallet-preimage = { workspace = true, default-features = true } +pallet-recovery = { workspace = true, default-features = true } +pallet-reversible-transfers = { path = "../reversible-transfers", default-features = true } +pallet-scheduler = { workspace = true, default-features = true } pallet-timestamp.workspace = true +pallet-utility = { workspace = true, default-features = true } +qp-scheduler = { workspace = true, default-features = true } sp-core.workspace = true sp-io.workspace = true +sp-runtime = { workspace = true, default-features = true } [features] default = ["std"] diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 0798c1f2..cb268594 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -33,7 +33,86 @@ mod benchmarks { use super::*; use codec::Encode; use frame_support::traits::ReservableCurrency; - use frame_system::RawOrigin; + use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin}; + + // ---------- Reusable setup helpers (keep benchmark bodies focused on what we measure) + // ---------- + + /// Funded caller + signers (sorted). Caller is first in the list. + fn setup_funded_signer_set( + signer_count: u32, + ) -> (T::AccountId, Vec) + where + BalanceOf2: From, + { + let caller: T::AccountId = whitelisted_caller(); + fund_account::(&caller, BalanceOf2::::from(100_000u128)); + let mut signers = vec![caller.clone()]; + for i in 0..signer_count.saturating_sub(1) { + let s: T::AccountId = account("signer", i, SEED); + fund_account::(&s, BalanceOf2::::from(100_000u128)); + signers.push(s); + } + signers.sort(); + (caller, signers) + } + + /// Insert multisig into storage (bypasses create_multisig). Returns multisig address. + fn insert_multisig( + caller: &T::AccountId, + signers: &[T::AccountId], + threshold: u32, + nonce: u64, + proposal_nonce: u32, + active_proposals: u32, + ) -> T::AccountId { + let multisig_address = Multisig::::derive_multisig_address(signers, threshold, nonce); + let bounded_signers: BoundedSignersOf = signers.to_vec().try_into().unwrap(); + let data = MultisigDataOf:: { + creator: caller.clone(), + signers: bounded_signers, + threshold, + proposal_nonce, + deposit: T::MultisigDeposit::get(), + active_proposals, + proposals_per_signer: BoundedBTreeMap::new(), + }; + Multisigs::::insert(&multisig_address, data); + multisig_address + } + + fn set_block(n: u32) + where + BlockNumberFor: From, + { + frame_system::Pallet::::set_block_number(n.into()); + } + + /// Insert a single proposal into storage. `approvals` = list of account ids that have approved. + fn insert_proposal( + multisig_address: &T::AccountId, + proposal_id: u32, + proposer: &T::AccountId, + call_size: u32, + expiry: BlockNumberFor, + approvals: &[T::AccountId], + status: ProposalStatus, + deposit: crate::BalanceOf, + ) { + let system_call = frame_system::Call::::remark { remark: vec![1u8; call_size as usize] }; + let encoded = ::RuntimeCall::from(system_call).encode(); + let bounded_call: BoundedCallOf = encoded.try_into().unwrap(); + let bounded_approvals: BoundedApprovalsOf = approvals.to_vec().try_into().unwrap(); + let proposal_data = ProposalDataOf:: { + proposer: proposer.clone(), + call: bounded_call, + expiry, + approvals: bounded_approvals, + deposit, + status, + }; + Proposals::::insert(multisig_address, proposal_id, proposal_data); + } /// Benchmark `create_multisig` extrinsic. /// Parameter: s = signers count @@ -73,33 +152,10 @@ mod benchmarks { fn propose( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { - let caller: T::AccountId = whitelisted_caller(); - fund_account::(&caller, BalanceOf2::::from(100000u128)); - - let signer1: T::AccountId = account("signer1", 0, SEED); - let signer2: T::AccountId = account("signer2", 1, SEED); - fund_account::(&signer1, BalanceOf2::::from(100000u128)); - fund_account::(&signer2, BalanceOf2::::from(100000u128)); - - let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; - signers.sort(); - - // Create multisig directly in storage (empty, no existing proposals) - let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); - let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); - let multisig_data = MultisigDataOf:: { - creator: caller.clone(), - signers: bounded_signers, - threshold, - proposal_nonce: 0, - deposit: T::MultisigDeposit::get(), - active_proposals: 0, - proposals_per_signer: BoundedBTreeMap::new(), - }; - Multisigs::::insert(&multisig_address, multisig_data); - - frame_system::Pallet::::set_block_number(100u32.into()); + let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 0, 0); + set_block::(100); let new_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; let encoded_call = ::RuntimeCall::from(new_call).encode(); @@ -108,57 +164,27 @@ mod benchmarks { #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); - // Verify proposal was created let multisig = Multisigs::::get(&multisig_address).unwrap(); assert_eq!(multisig.active_proposals, 1); - Ok(()) } /// Benchmark `propose` for high-security multisigs (includes decode + whitelist check). - /// More expensive than normal propose due to: - /// 1. is_high_security() check (1 DB read from ReversibleTransfers::HighSecurityAccounts) - /// 2. RuntimeCall decode (O(c) overhead - scales with call size) - /// 3. is_whitelisted() pattern matching /// Parameter: c = call size #[benchmark] fn propose_high_security( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { - let caller: T::AccountId = whitelisted_caller(); - fund_account::(&caller, BalanceOf2::::from(100000u128)); - - let signer1: T::AccountId = account("signer1", 0, SEED); - let signer2: T::AccountId = account("signer2", 1, SEED); - fund_account::(&signer1, BalanceOf2::::from(100000u128)); - fund_account::(&signer2, BalanceOf2::::from(100000u128)); - - let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; - signers.sort(); + let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 0, 0); - // Create multisig directly in storage (empty, no existing proposals) - let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); - let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); - let multisig_data = MultisigDataOf:: { - creator: caller.clone(), - signers: bounded_signers, - threshold, - proposal_nonce: 0, - deposit: T::MultisigDeposit::get(), - active_proposals: 0, - proposals_per_signer: BoundedBTreeMap::new(), - }; - Multisigs::::insert(&multisig_address, multisig_data); - - // Set this multisig as high-security for benchmarking #[cfg(feature = "runtime-benchmarks")] { use pallet_reversible_transfers::{ benchmarking::insert_hs_account_for_benchmark, HighSecurityAccountData, }; use qp_scheduler::BlockNumberOrTimestamp; - let hs_data = HighSecurityAccountData { interceptor: multisig_address.clone(), delay: BlockNumberOrTimestamp::BlockNumber(100u32.into()), @@ -166,10 +192,8 @@ mod benchmarks { insert_hs_account_for_benchmark::(multisig_address.clone(), hs_data); } - frame_system::Pallet::::set_block_number(100u32.into()); + set_block::(100); - // Whitelisted call with variable size to measure decode cost O(c) - // NOTE: system::remark is whitelisted ONLY in runtime-benchmarks mode let new_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; let encoded_call = ::RuntimeCall::from(new_call).encode(); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); @@ -177,86 +201,39 @@ mod benchmarks { #[extrinsic_call] propose(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); - // Verify proposal was created let multisig = Multisigs::::get(&multisig_address).unwrap(); assert_eq!(multisig.active_proposals, 1); - Ok(()) } - /// Benchmark `approve` extrinsic (without execution). + /// Benchmark `approve` extrinsic (without execution). Threshold 3, so 1 approval added → 2/3. /// Parameter: c = call size (stored proposal call) #[benchmark] fn approve( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { - // NOTE: approve() does NOT do auto-cleanup (removed for predictable gas costs) - // So we don't need to test with expired proposals - - // Setup: Create multisig and proposal directly in storage - // Threshold is 3, so adding one more approval won't trigger execution - let caller: T::AccountId = whitelisted_caller(); - fund_account::(&caller, BalanceOf2::::from(100000u128)); - - let signer1: T::AccountId = account("signer1", 0, SEED); - let signer2: T::AccountId = account("signer2", 1, SEED); - let signer3: T::AccountId = account("signer3", 2, SEED); - fund_account::(&signer1, BalanceOf2::::from(100000u128)); - fund_account::(&signer2, BalanceOf2::::from(100000u128)); - fund_account::(&signer3, BalanceOf2::::from(100000u128)); - - let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone(), signer3.clone()]; - let threshold = 3u32; // Need 3 approvals - - // Sort signers to match create_multisig behavior - signers.sort(); - - // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); - let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); - let multisig_data = MultisigDataOf:: { - creator: caller.clone(), - signers: bounded_signers, - threshold, - proposal_nonce: 1, // One active proposal - deposit: T::MultisigDeposit::get(), - active_proposals: 1, - proposals_per_signer: BoundedBTreeMap::new(), - }; - Multisigs::::insert(&multisig_address, multisig_data); - - // Set current block to avoid expiry issues - frame_system::Pallet::::set_block_number(100u32.into()); - - // Directly insert active proposal into storage with 1 approval - // Create a remark call where the remark itself is c bytes - let system_call = frame_system::Call::::remark { remark: vec![1u8; c as usize] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); + let (caller, signers) = setup_funded_signer_set::(4); // caller + 3 signers + let threshold = 3u32; + let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); + set_block::(100); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); - let bounded_call: BoundedCallOf = encoded_call.clone().try_into().unwrap(); - let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - let proposal_data = ProposalDataOf:: { - proposer: caller.clone(), - call: bounded_call, + insert_proposal::( + &multisig_address, + 0, + &caller, + c, expiry, - approvals: bounded_approvals, - deposit: 10u32.into(), - status: ProposalStatus::Active, - }; - - let proposal_id = 0; // Single active proposal - Proposals::::insert(&multisig_address, proposal_id, proposal_data); + &[caller.clone()], + ProposalStatus::Active, + 10u32.into(), + ); + let signer1 = signers[1].clone(); #[extrinsic_call] - _(RawOrigin::Signed(signer1.clone()), multisig_address.clone(), proposal_id); - - // Verify approval was added (now 2/3, not executed yet) - let proposal = Proposals::::get(&multisig_address, proposal_id).unwrap(); - assert!(proposal.approvals.contains(&signer1)); - assert_eq!(proposal.approvals.len(), 2); + _(RawOrigin::Signed(signer1), multisig_address.clone(), 0u32); + let proposal = Proposals::::get(&multisig_address, 0).unwrap(); + assert!(proposal.approvals.len() == 2); Ok(()) } @@ -266,183 +243,79 @@ mod benchmarks { fn execute( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { - let caller: T::AccountId = whitelisted_caller(); - fund_account::(&caller, BalanceOf2::::from(10000u128)); - - let signer1: T::AccountId = account("signer1", 0, SEED); - let signer2: T::AccountId = account("signer2", 1, SEED); - fund_account::(&signer1, BalanceOf2::::from(10000u128)); - fund_account::(&signer2, BalanceOf2::::from(10000u128)); - - let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; - signers.sort(); - - let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); - let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); - let multisig_data = MultisigDataOf:: { - creator: caller.clone(), - signers: bounded_signers, - threshold, - proposal_nonce: 1, - deposit: T::MultisigDeposit::get(), - active_proposals: 1, - proposals_per_signer: BoundedBTreeMap::new(), - }; - Multisigs::::insert(&multisig_address, multisig_data); - - // Insert an Approved proposal (threshold already reached) - let system_call = frame_system::Call::::remark { remark: vec![1u8; c as usize] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); + let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); + set_block::(100); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); - let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); - let bounded_approvals: BoundedApprovalsOf = - vec![caller.clone(), signer1.clone()].try_into().unwrap(); - - let proposal_data = ProposalDataOf:: { - proposer: caller.clone(), - call: bounded_call, + // Approved = caller + signers[1] (2/2) + insert_proposal::( + &multisig_address, + 0, + &caller, + c, expiry, - approvals: bounded_approvals, - deposit: 10u32.into(), - status: ProposalStatus::Approved, - }; - - let proposal_id = 0u32; - Proposals::::insert(&multisig_address, proposal_id, proposal_data); + &[caller.clone(), signers[1].clone()], + ProposalStatus::Approved, + 10u32.into(), + ); + let executor = signers[2].clone(); #[extrinsic_call] - _(RawOrigin::Signed(signer2.clone()), multisig_address.clone(), proposal_id); - - // Verify proposal was removed from storage after execution - assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); + _(RawOrigin::Signed(executor), multisig_address.clone(), 0u32); + assert!(!Proposals::::contains_key(&multisig_address, 0)); Ok(()) } #[benchmark] fn cancel() -> Result<(), BenchmarkError> { - // Setup: Create multisig and proposal directly in storage - let caller: T::AccountId = whitelisted_caller(); - fund_account::(&caller, BalanceOf2::::from(100000u128)); - - let signer1: T::AccountId = account("signer1", 0, SEED); - let signer2: T::AccountId = account("signer2", 1, SEED); - - let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; - - // Sort signers to match create_multisig behavior - signers.sort(); - - // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); - let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); - let multisig_data = MultisigDataOf:: { - creator: caller.clone(), - signers: bounded_signers, - threshold, - proposal_nonce: 1, - deposit: T::MultisigDeposit::get(), - active_proposals: 1, - proposals_per_signer: BoundedBTreeMap::new(), - }; - Multisigs::::insert(&multisig_address, multisig_data); - - // Directly insert active proposal into storage - let system_call = frame_system::Call::::remark { remark: vec![1u8; 10] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); + let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); + set_block::(100); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); - let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); - let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - let proposal_data = ProposalDataOf:: { - proposer: caller.clone(), - call: bounded_call, + insert_proposal::( + &multisig_address, + 0, + &caller, + 10, expiry, - approvals: bounded_approvals, - deposit: T::ProposalDeposit::get(), - status: ProposalStatus::Active, - }; - - let proposal_id = 0; - Proposals::::insert(&multisig_address, proposal_id, proposal_data); - - // Reserve deposit for proposer + &[caller.clone()], + ProposalStatus::Active, + T::ProposalDeposit::get(), + ); ::Currency::reserve(&caller, T::ProposalDeposit::get()).unwrap(); #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_id); - - // Verify proposal was removed from storage - assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), 0u32); + assert!(!Proposals::::contains_key(&multisig_address, 0)); Ok(()) } #[benchmark] fn remove_expired() -> Result<(), BenchmarkError> { - // Setup: Create multisig and expired proposal directly in storage - let caller: T::AccountId = whitelisted_caller(); - fund_account::(&caller, BalanceOf2::::from(10000u128)); - - let signer1: T::AccountId = account("signer1", 0, SEED); - let signer2: T::AccountId = account("signer2", 1, SEED); - fund_account::(&signer1, BalanceOf2::::from(10000u128)); - fund_account::(&signer2, BalanceOf2::::from(10000u128)); - - let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; - - // Sort signers to match create_multisig behavior - signers.sort(); - - // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); - let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); - let multisig_data = MultisigDataOf:: { - creator: caller.clone(), - signers: bounded_signers, - threshold, - proposal_nonce: 1, // We'll insert proposal with id 0 - deposit: T::MultisigDeposit::get(), - active_proposals: 1, - proposals_per_signer: BoundedBTreeMap::new(), - }; - Multisigs::::insert(&multisig_address, multisig_data); - - // Create proposal with expired timestamp - let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; - let call = ::RuntimeCall::from(system_call); - let encoded_call = call.encode(); - let expiry = 10u32.into(); // Already expired - let bounded_call: BoundedCallOf = encoded_call.clone().try_into().unwrap(); - let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - let proposal_data = ProposalDataOf:: { - proposer: caller.clone(), - call: bounded_call, + let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); + let expiry = 10u32.into(); + insert_proposal::( + &multisig_address, + 0, + &caller, + 32, expiry, - approvals: bounded_approvals, - deposit: 10u32.into(), - status: ProposalStatus::Active, - }; + &[caller.clone()], + ProposalStatus::Active, + 10u32.into(), + ); + set_block::(100); - let proposal_id = 0u32; - Proposals::::insert(&multisig_address, proposal_id, proposal_data); - - // Move past expiry - frame_system::Pallet::::set_block_number(100u32.into()); - - // Call as signer (caller is one of signers) #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_id); - - // Verify proposal was removed - assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), 0u32); + assert!(!Proposals::::contains_key(&multisig_address, 0)); Ok(()) } @@ -453,133 +326,58 @@ mod benchmarks { i: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, r: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, ) -> Result<(), BenchmarkError> { - // cleaned_target = min(r, i): can't clean more proposals than we iterate let cleaned_target = (r as u32).min(i); - - // Total proposals = i (maps directly to iteration parameter) - // No edge case needed here: claim_deposits doesn't create a new proposal, - // so there's no `total < Max` check to worry about. let total_proposals = i; - // Setup: Create multisig with 3 signers and multiple proposals - let caller: T::AccountId = whitelisted_caller(); - fund_account::(&caller, BalanceOf2::::from(100000u128)); - - let signer1: T::AccountId = account("signer1", 0, SEED); - let signer2: T::AccountId = account("signer2", 1, SEED); - fund_account::(&signer1, BalanceOf2::::from(100000u128)); - fund_account::(&signer2, BalanceOf2::::from(100000u128)); - - let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; + let multisig_address = + insert_multisig::(&caller, &signers, threshold, 0, total_proposals, total_proposals); - // Sort signers to match create_multisig behavior - signers.sort(); - - // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); - let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); - let multisig_data = MultisigDataOf:: { - creator: caller.clone(), - signers: bounded_signers, - threshold, - proposal_nonce: total_proposals, - deposit: T::MultisigDeposit::get(), - active_proposals: total_proposals, - proposals_per_signer: BoundedBTreeMap::new(), - }; - Multisigs::::insert(&multisig_address, multisig_data); - - // Build proposal template once - only expiry varies per proposal - let template_call: BoundedCallOf = { - let system_call = frame_system::Call::::remark { remark: vec![0u8; 32] }; - ::RuntimeCall::from(system_call).encode().try_into().unwrap() - }; - let template_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); - - // Insert proposals: first `cleaned_target` are expired, rest are non-expired. - // This separates iteration cost (read all total_proposals) from cleanup cost - // (delete cleaned_target). let expired_block = 10u32.into(); let future_block = 999999u32.into(); for idx in 0..total_proposals { let expiry = if idx < cleaned_target { expired_block } else { future_block }; - Proposals::::insert( + insert_proposal::( &multisig_address, idx, - ProposalDataOf:: { - proposer: caller.clone(), - call: template_call.clone(), - expiry, - approvals: template_approvals.clone(), - deposit: 10u32.into(), - status: ProposalStatus::Active, - }, + &caller, + 32, + expiry, + &[caller.clone()], + ProposalStatus::Active, + 10u32.into(), ); } - // Move past expired_block but before future_block - frame_system::Pallet::::set_block_number(100u32.into()); + set_block::(100); #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone()); - // Verify: only non-expired proposals remain let remaining = Proposals::::iter_key_prefix(&multisig_address).count() as u32; assert_eq!(remaining, total_proposals - cleaned_target); - Ok(()) } #[benchmark] fn dissolve_multisig() -> Result<(), BenchmarkError> { - // Setup: Create a clean multisig (no proposals, zero balance) - let caller: T::AccountId = whitelisted_caller(); - fund_account::(&caller, BalanceOf2::::from(10000u128)); - - let signer1: T::AccountId = account("signer1", 0, SEED); - let signer2: T::AccountId = account("signer2", 1, SEED); - - let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; - - // Sort signers to match create_multisig behavior - signers.sort(); - - // Directly insert multisig into storage - let multisig_address = Multisig::::derive_multisig_address(&signers, threshold, 0); - let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); let deposit = T::MultisigDeposit::get(); - - // Reserve deposit from caller ::Currency::reserve(&caller, deposit)?; - let multisig_data = MultisigDataOf:: { - creator: caller.clone(), - signers: bounded_signers.clone(), - threshold, - proposal_nonce: 0, - deposit, - active_proposals: 0, // No proposals - proposals_per_signer: BoundedBTreeMap::new(), - }; - Multisigs::::insert(&multisig_address, multisig_data); - - // Add first approval (signer1) + let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 0, 0); + // Pre-insert one approval from a signer that is NOT the caller (avoid AlreadyApproved). + let first_approval = signers.iter().find(|s| *s != &caller).unwrap().clone(); let mut approvals = BoundedApprovalsOf::::default(); - approvals.try_push(signer1.clone()).unwrap(); + approvals.try_push(first_approval).unwrap(); DissolveApprovals::::insert(&multisig_address, approvals); - // Ensure multisig address has zero balance (required for dissolution) - // Don't fund it at all - - // Benchmark the final approval that triggers dissolution #[extrinsic_call] approve_dissolve(RawOrigin::Signed(caller.clone()), multisig_address.clone()); - // Verify multisig was removed assert!(!Multisigs::::contains_key(&multisig_address)); - Ok(()) } diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index 4ef3b258..514925b4 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -1,89 +1,121 @@ -//! Mock runtime for testing pallet-multisig +//! Mock runtime for testing pallet-multisig. +//! Single mock used for both unit tests and benchmark tests; implements +//! `pallet_reversible_transfers::Config` so that benchmark test suite compiles and runs. + +use core::{cell::RefCell, marker::PhantomData}; use crate as pallet_multisig; use frame_support::{ - parameter_types, - traits::{ConstU32, Everything}, + derive_impl, ord_parameter_types, parameter_types, + traits::{ConstU32, EitherOfDiverse, EqualPrivilegeOnly, Time}, PalletId, }; -use sp_core::{crypto::AccountId32, H256}; -use sp_runtime::{ - traits::{BlakeTwo256, IdentityLookup}, - BuildStorage, Permill, -}; +use frame_system::{limits::BlockWeights, EnsureRoot, EnsureSignedBy}; +use qp_scheduler::BlockNumberOrTimestamp; +use sp_core::ConstU128; +use sp_runtime::{BuildStorage, Perbill, Permill, Weight}; type Block = frame_system::mocking::MockBlock; -type Balance = u128; - -// Configure a mock runtime to test the pallet. -frame_support::construct_runtime!( - pub enum Test - { - System: frame_system, - Balances: pallet_balances, - Multisig: pallet_multisig, +pub type Balance = u128; +pub type AccountId = sp_core::crypto::AccountId32; + +// account_id from u64 (first 8 bytes = id.to_le_bytes()) — same as in tests +pub fn account_id(id: u64) -> AccountId { + let mut data = [0u8; 32]; + data[0..8].copy_from_slice(&id.to_le_bytes()); + AccountId::new(data) +} + +#[frame_support::runtime] +mod runtime { + use super::*; + + #[runtime::runtime] + #[runtime::derive( + RuntimeCall, + RuntimeEvent, + RuntimeError, + RuntimeOrigin, + RuntimeFreezeReason, + RuntimeHoldReason, + RuntimeSlashReason, + RuntimeLockId, + RuntimeTask + )] + pub struct Test; + + #[runtime::pallet_index(0)] + pub type System = frame_system::Pallet; + + #[runtime::pallet_index(1)] + pub type Balances = pallet_balances::Pallet; + + #[runtime::pallet_index(2)] + pub type Multisig = pallet_multisig::Pallet; + + #[runtime::pallet_index(3)] + pub type Preimage = pallet_preimage::Pallet; + + #[runtime::pallet_index(4)] + pub type Scheduler = pallet_scheduler::Pallet; + + #[runtime::pallet_index(5)] + pub type Recovery = pallet_recovery::Pallet; + + #[runtime::pallet_index(6)] + pub type Utility = pallet_utility::Pallet; + + #[runtime::pallet_index(7)] + pub type Assets = pallet_assets::Pallet; + + #[runtime::pallet_index(8)] + pub type AssetsHolder = pallet_assets_holder::Pallet; + + #[runtime::pallet_index(9)] + pub type ReversibleTransfers = pallet_reversible_transfers::Pallet; +} + +impl TryFrom for pallet_balances::Call { + type Error = (); + fn try_from(call: RuntimeCall) -> Result { + match call { + RuntimeCall::Balances(c) => Ok(c), + _ => Err(()), + } } -); +} -parameter_types! { - pub const BlockHashCount: u64 = 250; +impl TryFrom for pallet_assets::Call { + type Error = (); + fn try_from(call: RuntimeCall) -> Result { + match call { + RuntimeCall::Assets(c) => Ok(c), + _ => Err(()), + } + } } +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] impl frame_system::Config for Test { - type RuntimeEvent = RuntimeEvent; - type BaseCallFilter = Everything; type Block = Block; - type BlockWeights = (); - type BlockLength = (); - type DbWeight = (); - type RuntimeOrigin = RuntimeOrigin; - type RuntimeCall = RuntimeCall; - type Nonce = u64; - type Hash = H256; - type Hashing = BlakeTwo256; - type AccountId = AccountId32; - type Lookup = IdentityLookup; - type BlockHashCount = BlockHashCount; - type Version = (); - type PalletInfo = PalletInfo; + type AccountId = AccountId; + type Lookup = sp_runtime::traits::IdentityLookup; type AccountData = pallet_balances::AccountData; - type OnNewAccount = (); - type OnKilledAccount = (); - type SystemWeightInfo = (); - type SS58Prefix = (); - type OnSetCode = (); - type MaxConsumers = ConstU32<16>; - type RuntimeTask = (); - type SingleBlockMigrations = (); - type MultiBlockMigrator = (); - type PreInherents = (); - type PostInherents = (); - type PostTransactions = (); - type ExtensionsWeightInfo = (); } parameter_types! { - pub const ExistentialDeposit: Balance = 1; - pub const MaxLocks: u32 = 50; - pub const MaxReserves: u32 = 50; - pub const MaxFreezes: u32 = 50; - pub const MintingAccount: AccountId32 = AccountId32::new([99u8; 32]); + pub MintingAccount: AccountId = AccountId::new([1u8; 32]); } +#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)] impl pallet_balances::Config for Test { - type WeightInfo = (); type Balance = Balance; type DustRemoval = (); - type ExistentialDeposit = ExistentialDeposit; - type AccountStore = System; - type MaxLocks = MaxLocks; - type MaxReserves = MaxReserves; - type ReserveIdentifier = [u8; 8]; + type ExistentialDeposit = ConstU128<1>; + type AccountStore = frame_system::Pallet; + type WeightInfo = (); type RuntimeHoldReason = RuntimeHoldReason; - type RuntimeFreezeReason = RuntimeFreezeReason; - type FreezeIdentifier = (); - type MaxFreezes = MaxFreezes; - type DoneSlashHandler = (); + type MaxFreezes = MaxReversibleTransfers; type MintingAccount = MintingAccount; } @@ -92,12 +124,12 @@ parameter_types! { pub const MaxSignersParam: u32 = 10; pub const MaxTotalProposalsInStorageParam: u32 = 20; pub const MaxCallSizeParam: u32 = 1024; - pub const MultisigFeeParam: Balance = 1000; // Non-refundable fee - pub const MultisigDepositParam: Balance = 500; // Refundable deposit + pub const MultisigFeeParam: Balance = 1000; + pub const MultisigDepositParam: Balance = 500; pub const ProposalDepositParam: Balance = 100; - pub const ProposalFeeParam: Balance = 1000; // Non-refundable fee - pub const SignerStepFactorParam: Permill = Permill::from_parts(10_000); // 1% - pub const MaxExpiryDurationParam: u64 = 10000; // 10000 blocks for testing (enough for all test scenarios) + pub const ProposalFeeParam: Balance = 1000; + pub const SignerStepFactorParam: Permill = Permill::from_parts(10_000); + pub const MaxExpiryDurationParam: u64 = 10000; } impl pallet_multisig::Config for Test { @@ -117,28 +149,174 @@ impl pallet_multisig::Config for Test { type HighSecurity = crate::tests::MockHighSecurity; } -// Helper to create AccountId32 from u64 -pub fn account_id(id: u64) -> AccountId32 { - let mut data = [0u8; 32]; - data[0..8].copy_from_slice(&id.to_le_bytes()); - AccountId32::new(data) +type Moment = u64; + +thread_local! { + static MOCKED_TIME: RefCell = const { RefCell::new(69420) }; +} + +pub struct MockTimestamp(PhantomData); + +impl MockTimestamp +where + T::Moment: From, +{ + pub fn set_timestamp(now: Moment) { + MOCKED_TIME.with(|v| *v.borrow_mut() = now); + } +} + +impl Time for MockTimestamp { + type Moment = Moment; + fn now() -> Self::Moment { + MOCKED_TIME.with(|v| *v.borrow()) + } +} + +parameter_types! { + pub const ReversibleTransfersPalletIdValue: PalletId = PalletId(*b"rtpallet"); + pub const DefaultDelay: BlockNumberOrTimestamp = + BlockNumberOrTimestamp::BlockNumber(10); + pub const MinDelayPeriodBlocks: u64 = 2; + pub const MinDelayPeriodMoment: u64 = 2000; + pub const MaxReversibleTransfers: u32 = 100; + pub const MaxInterceptorAccounts: u32 = 10; + pub const HighSecurityVolumeFee: Permill = Permill::from_percent(1); +} + +impl pallet_reversible_transfers::Config for Test { + type SchedulerOrigin = OriginCaller; + type RuntimeHoldReason = RuntimeHoldReason; + type Scheduler = Scheduler; + type BlockNumberProvider = System; + type MaxPendingPerAccount = MaxReversibleTransfers; + type DefaultDelay = DefaultDelay; + type MinDelayPeriodBlocks = MinDelayPeriodBlocks; + type MinDelayPeriodMoment = MinDelayPeriodMoment; + type PalletId = ReversibleTransfersPalletIdValue; + type Preimages = Preimage; + type WeightInfo = (); + type Moment = Moment; + type TimeProvider = MockTimestamp; + type MaxInterceptorAccounts = MaxInterceptorAccounts; + type VolumeFee = HighSecurityVolumeFee; +} + +parameter_types! { + pub const AssetDeposit: Balance = 0; + pub const AssetAccountDeposit: Balance = 0; + pub const AssetsStringLimit: u32 = 50; + pub const MetadataDepositBase: Balance = 0; + pub const MetadataDepositPerByte: Balance = 0; +} + +impl pallet_assets::Config for Test { + type Balance = Balance; + type RuntimeEvent = RuntimeEvent; + type AssetId = u32; + type AssetIdParameter = codec::Compact; + type Currency = Balances; + type CreateOrigin = + frame_support::traits::AsEnsureOriginWithArg>; + type ForceOrigin = frame_system::EnsureRoot; + type AssetDeposit = AssetDeposit; + type MetadataDepositBase = MetadataDepositBase; + type MetadataDepositPerByte = MetadataDepositPerByte; + type ApprovalDeposit = sp_core::ConstU128<0>; + type StringLimit = AssetsStringLimit; + type Freezer = (); + type Extra = (); + type WeightInfo = (); + type CallbackHandle = pallet_assets::AutoIncAssetId; + type AssetAccountDeposit = AssetAccountDeposit; + type RemoveItemsLimit = frame_support::traits::ConstU32<1000>; + type Holder = pallet_assets_holder::Pallet; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = (); +} + +impl pallet_assets_holder::Config for Test { + type RuntimeEvent = RuntimeEvent; + type RuntimeHoldReason = RuntimeHoldReason; +} + +parameter_types! { + pub const ConfigDepositBase: Balance = 1; + pub const FriendDepositFactor: Balance = 1; + pub const MaxFriends: u32 = 9; + pub const RecoveryDeposit: Balance = 1; +} + +impl pallet_recovery::Config for Test { + type WeightInfo = (); + type RuntimeCall = RuntimeCall; + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type ConfigDepositBase = ConfigDepositBase; + type FriendDepositFactor = FriendDepositFactor; + type MaxFriends = MaxFriends; + type RecoveryDeposit = RecoveryDeposit; + type BlockNumberProvider = System; +} + +impl pallet_preimage::Config for Test { + type WeightInfo = (); + type Currency = (); + type ManagerOrigin = EnsureRoot; + type Consideration = (); + type RuntimeEvent = RuntimeEvent; +} + +parameter_types! { + pub storage MaximumSchedulerWeight: Weight = + Perbill::from_percent(80) * BlockWeights::default().max_block; + pub const TimestampBucketSize: u64 = 1000; +} + +ord_parameter_types! { + pub const One: AccountId = AccountId::new([1u8; 32]); +} + +impl pallet_scheduler::Config for Test { + type RuntimeOrigin = RuntimeOrigin; + type PalletsOrigin = OriginCaller; + type RuntimeCall = RuntimeCall; + type MaximumWeight = MaximumSchedulerWeight; + type ScheduleOrigin = EitherOfDiverse, EnsureSignedBy>; + type OriginPrivilegeCmp = EqualPrivilegeOnly; + type MaxScheduledPerBlock = ConstU32<10>; + type WeightInfo = (); + type Preimages = Preimage; + type Moment = Moment; + type TimeProvider = MockTimestamp; + type TimestampBucketSize = TimestampBucketSize; +} + +impl pallet_utility::Config for Test { + type RuntimeEvent = RuntimeEvent; + type RuntimeCall = RuntimeCall; + type PalletsOrigin = OriginCaller; + type WeightInfo = (); } -// Build genesis storage according to the mock runtime. pub fn new_test_ext() -> sp_io::TestExternalities { let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); pallet_balances::GenesisConfig:: { balances: vec![ - (account_id(1), 100000), // Alice - (account_id(2), 200000), // Bob - (account_id(3), 300000), // Charlie - (account_id(4), 400000), // Dave - (account_id(5), 500000), // Eve + (account_id(1), 100_000), + (account_id(2), 200_000), + (account_id(3), 300_000), + (account_id(4), 400_000), + (account_id(5), 500_000), ], } .assimilate_storage(&mut t) .unwrap(); + pallet_reversible_transfers::GenesisConfig:: { initial_high_security_accounts: vec![] } + .assimilate_storage(&mut t) + .unwrap(); + t.into() } diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index c13160f8..07aff40c 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-02-10, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-02-10, STEPS: `20`, REPEAT: `50`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -28,13 +28,20 @@ // ./target/release/quantus-node // benchmark // pallet -// --chain=dev -// --pallet=pallet_multisig -// --extrinsic=* -// --steps=50 -// --repeat=20 -// --output=./pallets/multisig/src/weights.rs -// --template=./.maintain/frame-weight-template.hbs +// --chain +// dev +// --pallet +// pallet_multisig +// --extrinsic +// * +// --steps +// 20 +// --repeat +// 50 +// --output +// pallets/multisig/src/weights.rs +// --template +// .maintain/frame-weight-template.hbs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -68,10 +75,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 177_000_000 picoseconds. - Weight::from_parts(117_547_346, 10377) - // Standard Error: 37_775 - .saturating_add(Weight::from_parts(4_883_267, 0).saturating_mul(s.into())) + // Minimum execution time: 188_000_000 picoseconds. + Weight::from_parts(122_679_429, 10377) + // Standard Error: 34_069 + .saturating_add(Weight::from_parts(4_701_084, 0).saturating_mul(s.into())) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -82,12 +89,14 @@ impl WeightInfo for SubstrateWeight { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn propose(_c: u32, ) -> Weight { + fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `678` // Estimated: `17022` - // Minimum execution time: 69_000_000 picoseconds. - Weight::from_parts(76_077_943, 17022) + // Minimum execution time: 68_000_000 picoseconds. + Weight::from_parts(70_446_980, 17022) + // Standard Error: 45 + .saturating_add(Weight::from_parts(809, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -102,10 +111,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 38_000_000 picoseconds. - Weight::from_parts(42_099_445, 17022) - // Standard Error: 76 - .saturating_add(Weight::from_parts(457, 0).saturating_mul(c.into())) + // Minimum execution time: 36_000_000 picoseconds. + Weight::from_parts(40_941_329, 17022) + // Standard Error: 47 + .saturating_add(Weight::from_parts(306, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -118,10 +127,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(13_064_381, 17022) - // Standard Error: 33 - .saturating_add(Weight::from_parts(705, 0).saturating_mul(c.into())) + // Minimum execution time: 11_000_000 picoseconds. + Weight::from_parts(13_631_995, 17022) + // Standard Error: 17 + .saturating_add(Weight::from_parts(305, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -130,12 +139,14 @@ impl WeightInfo for SubstrateWeight { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn execute(_c: u32, ) -> Weight { + fn execute(c: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(27_562_800, 17022) + Weight::from_parts(23_953_556, 17022) + // Standard Error: 28 + .saturating_add(Weight::from_parts(683, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -147,8 +158,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `730` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(24_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -160,8 +171,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `752` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -173,14 +184,14 @@ impl WeightInfo for SubstrateWeight { /// The range of component `r` is `[1, 200]`. fn claim_deposits(i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `741 + i * (115 ±0)` + // Measured: `735 + i * (115 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 24_000_000 picoseconds. - Weight::from_parts(24_000_000, 17022) - // Standard Error: 130_229 - .saturating_add(Weight::from_parts(6_989_300, 0).saturating_mul(i.into())) - // Standard Error: 130_229 - .saturating_add(Weight::from_parts(4_096_261, 0).saturating_mul(r.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(23_000_000, 17022) + // Standard Error: 126_726 + .saturating_add(Weight::from_parts(6_633_147, 0).saturating_mul(i.into())) + // Standard Error: 126_726 + .saturating_add(Weight::from_parts(3_973_506, 0).saturating_mul(r.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -198,8 +209,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 27_000_000 picoseconds. - Weight::from_parts(28_000_000, 17022) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(27_000_000, 17022) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -214,10 +225,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 177_000_000 picoseconds. - Weight::from_parts(117_547_346, 10377) - // Standard Error: 37_775 - .saturating_add(Weight::from_parts(4_883_267, 0).saturating_mul(s.into())) + // Minimum execution time: 188_000_000 picoseconds. + Weight::from_parts(122_679_429, 10377) + // Standard Error: 34_069 + .saturating_add(Weight::from_parts(4_701_084, 0).saturating_mul(s.into())) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -228,12 +239,14 @@ impl WeightInfo for () { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn propose(_c: u32, ) -> Weight { + fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `678` // Estimated: `17022` - // Minimum execution time: 69_000_000 picoseconds. - Weight::from_parts(76_077_943, 17022) + // Minimum execution time: 68_000_000 picoseconds. + Weight::from_parts(70_446_980, 17022) + // Standard Error: 45 + .saturating_add(Weight::from_parts(809, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -248,10 +261,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 38_000_000 picoseconds. - Weight::from_parts(42_099_445, 17022) - // Standard Error: 76 - .saturating_add(Weight::from_parts(457, 0).saturating_mul(c.into())) + // Minimum execution time: 36_000_000 picoseconds. + Weight::from_parts(40_941_329, 17022) + // Standard Error: 47 + .saturating_add(Weight::from_parts(306, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -264,10 +277,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(13_064_381, 17022) - // Standard Error: 33 - .saturating_add(Weight::from_parts(705, 0).saturating_mul(c.into())) + // Minimum execution time: 11_000_000 picoseconds. + Weight::from_parts(13_631_995, 17022) + // Standard Error: 17 + .saturating_add(Weight::from_parts(305, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -276,12 +289,14 @@ impl WeightInfo for () { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn execute(_c: u32, ) -> Weight { + fn execute(c: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(27_562_800, 17022) + Weight::from_parts(23_953_556, 17022) + // Standard Error: 28 + .saturating_add(Weight::from_parts(683, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -293,8 +308,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `730` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(24_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -306,8 +321,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `752` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -319,14 +334,14 @@ impl WeightInfo for () { /// The range of component `r` is `[1, 200]`. fn claim_deposits(i: u32, r: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `741 + i * (115 ±0)` + // Measured: `735 + i * (115 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 24_000_000 picoseconds. - Weight::from_parts(24_000_000, 17022) - // Standard Error: 130_229 - .saturating_add(Weight::from_parts(6_989_300, 0).saturating_mul(i.into())) - // Standard Error: 130_229 - .saturating_add(Weight::from_parts(4_096_261, 0).saturating_mul(r.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(23_000_000, 17022) + // Standard Error: 126_726 + .saturating_add(Weight::from_parts(6_633_147, 0).saturating_mul(i.into())) + // Standard Error: 126_726 + .saturating_add(Weight::from_parts(3_973_506, 0).saturating_mul(r.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -344,8 +359,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 27_000_000 picoseconds. - Weight::from_parts(28_000_000, 17022) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(27_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } From 3484efc76e7fb90aada1678c87c71af8ee85654f Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Wed, 11 Feb 2026 17:14:51 +0800 Subject: [PATCH 12/15] fix: Benchmarks - corrected HS multisig cost --- pallets/multisig/src/benchmarking.rs | 55 +++++++++----- pallets/multisig/src/weights.rs | 102 +++++++++++++------------- runtime/src/configs/mod.rs | 37 ++++++---- runtime/src/genesis_config_presets.rs | 40 ++++++++++ runtime/src/transaction_extensions.rs | 6 +- 5 files changed, 156 insertions(+), 84 deletions(-) diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index cb268594..2283dbcb 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -26,7 +26,7 @@ where #[benchmarks( where - T: Config + pallet_balances::Config + pallet_reversible_transfers::Config, + T: Config + pallet_balances::Config, BalanceOf2: From, )] mod benchmarks { @@ -34,6 +34,7 @@ mod benchmarks { use codec::Encode; use frame_support::traits::ReservableCurrency; use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin}; + use qp_high_security::HighSecurityInspector; // ---------- Reusable setup helpers (keep benchmark bodies focused on what we measure) // ---------- @@ -57,6 +58,24 @@ mod benchmarks { (caller, signers) } + /// Funded caller + signers matching genesis (signer1, signer2). Multisig address is in + /// ReversibleTransfers::initial_high_security_accounts when runtime-benchmarks. + fn setup_funded_signer_set_hs( + ) -> (T::AccountId, Vec) + where + BalanceOf2: From, + { + let caller: T::AccountId = whitelisted_caller(); + let signer1: T::AccountId = account("signer1", 0, SEED); + let signer2: T::AccountId = account("signer2", 1, SEED); + fund_account::(&caller, BalanceOf2::::from(100_000u128)); + fund_account::(&signer1, BalanceOf2::::from(100_000u128)); + fund_account::(&signer2, BalanceOf2::::from(100_000u128)); + let mut signers = vec![caller.clone(), signer1, signer2]; + signers.sort(); + (caller, signers) + } + /// Insert multisig into storage (bypasses create_multisig). Returns multisig address. fn insert_multisig( caller: &T::AccountId, @@ -147,14 +166,21 @@ mod benchmarks { } /// Benchmark `propose` extrinsic (non-HS path). - /// Parameter: c = call size + /// Uses different signers than propose_high_security so the multisig address is NOT in + /// HighSecurityAccounts (dev genesis records whitelisted_caller+signer1+signer2). No decode, no + /// whitelist. Parameter: c = call size #[benchmark] fn propose( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { + // Uses account("signer", 0/1) so multisig address differs from genesis (signer1/signer2). let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 0, 0); + assert!( + !T::HighSecurity::is_high_security(&multisig_address), + "propose must hit non-HS path" + ); set_block::(100); let new_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; @@ -169,29 +195,20 @@ mod benchmarks { Ok(()) } - /// Benchmark `propose` for high-security multisigs (includes decode + whitelist check). - /// Parameter: c = call size + /// Benchmark `propose` for high-security multisigs. + /// Uses signer1/signer2 so multisig address matches genesis (ReversibleTransfers:: + /// initial_high_security_accounts). HighSecurityAccounts::contains_key reads from trie. #[benchmark] fn propose_high_security( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { - let (caller, signers) = setup_funded_signer_set::(3); + let (caller, signers) = setup_funded_signer_set_hs::(); let threshold = 2u32; let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 0, 0); - - #[cfg(feature = "runtime-benchmarks")] - { - use pallet_reversible_transfers::{ - benchmarking::insert_hs_account_for_benchmark, HighSecurityAccountData, - }; - use qp_scheduler::BlockNumberOrTimestamp; - let hs_data = HighSecurityAccountData { - interceptor: multisig_address.clone(), - delay: BlockNumberOrTimestamp::BlockNumber(100u32.into()), - }; - insert_hs_account_for_benchmark::(multisig_address.clone(), hs_data); - } - + assert!( + T::HighSecurity::is_high_security(&multisig_address), + "propose_high_security must hit HS path" + ); set_block::(100); let new_call = frame_system::Call::::remark { remark: vec![99u8; c as usize] }; diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 07aff40c..655bf7cd 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-02-10, STEPS: `20`, REPEAT: `50`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-02-11, STEPS: `20`, REPEAT: `50`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -76,9 +76,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `152` // Estimated: `10377` // Minimum execution time: 188_000_000 picoseconds. - Weight::from_parts(122_679_429, 10377) - // Standard Error: 34_069 - .saturating_add(Weight::from_parts(4_701_084, 0).saturating_mul(s.into())) + Weight::from_parts(118_428_692, 10377) + // Standard Error: 35_339 + .saturating_add(Weight::from_parts(4_715_022, 0).saturating_mul(s.into())) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -91,12 +91,12 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `678` + // Measured: `838` // Estimated: `17022` - // Minimum execution time: 68_000_000 picoseconds. - Weight::from_parts(70_446_980, 17022) - // Standard Error: 45 - .saturating_add(Weight::from_parts(809, 0).saturating_mul(c.into())) + // Minimum execution time: 36_000_000 picoseconds. + Weight::from_parts(37_047_877, 17022) + // Standard Error: 8 + .saturating_add(Weight::from_parts(186, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -112,9 +112,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `838` // Estimated: `17022` // Minimum execution time: 36_000_000 picoseconds. - Weight::from_parts(40_941_329, 17022) - // Standard Error: 47 - .saturating_add(Weight::from_parts(306, 0).saturating_mul(c.into())) + Weight::from_parts(37_090_218, 17022) + // Standard Error: 7 + .saturating_add(Weight::from_parts(317, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -128,9 +128,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(13_631_995, 17022) - // Standard Error: 17 - .saturating_add(Weight::from_parts(305, 0).saturating_mul(c.into())) + Weight::from_parts(12_080_905, 17022) + // Standard Error: 5 + .saturating_add(Weight::from_parts(342, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -143,10 +143,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(23_953_556, 17022) - // Standard Error: 28 - .saturating_add(Weight::from_parts(683, 0).saturating_mul(c.into())) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_615_582, 17022) + // Standard Error: 6 + .saturating_add(Weight::from_parts(526, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -158,8 +158,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `730` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(24_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -188,10 +188,10 @@ impl WeightInfo for SubstrateWeight { // Estimated: `17022 + i * (16032 ±0)` // Minimum execution time: 22_000_000 picoseconds. Weight::from_parts(23_000_000, 17022) - // Standard Error: 126_726 - .saturating_add(Weight::from_parts(6_633_147, 0).saturating_mul(i.into())) - // Standard Error: 126_726 - .saturating_add(Weight::from_parts(3_973_506, 0).saturating_mul(r.into())) + // Standard Error: 123_314 + .saturating_add(Weight::from_parts(6_491_924, 0).saturating_mul(i.into())) + // Standard Error: 123_314 + .saturating_add(Weight::from_parts(3_850_325, 0).saturating_mul(r.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -210,7 +210,7 @@ impl WeightInfo for SubstrateWeight { // Measured: `703` // Estimated: `17022` // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(27_000_000, 17022) + Weight::from_parts(26_000_000, 17022) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -226,9 +226,9 @@ impl WeightInfo for () { // Measured: `152` // Estimated: `10377` // Minimum execution time: 188_000_000 picoseconds. - Weight::from_parts(122_679_429, 10377) - // Standard Error: 34_069 - .saturating_add(Weight::from_parts(4_701_084, 0).saturating_mul(s.into())) + Weight::from_parts(118_428_692, 10377) + // Standard Error: 35_339 + .saturating_add(Weight::from_parts(4_715_022, 0).saturating_mul(s.into())) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -241,12 +241,12 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `678` + // Measured: `838` // Estimated: `17022` - // Minimum execution time: 68_000_000 picoseconds. - Weight::from_parts(70_446_980, 17022) - // Standard Error: 45 - .saturating_add(Weight::from_parts(809, 0).saturating_mul(c.into())) + // Minimum execution time: 36_000_000 picoseconds. + Weight::from_parts(37_047_877, 17022) + // Standard Error: 8 + .saturating_add(Weight::from_parts(186, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -262,9 +262,9 @@ impl WeightInfo for () { // Measured: `838` // Estimated: `17022` // Minimum execution time: 36_000_000 picoseconds. - Weight::from_parts(40_941_329, 17022) - // Standard Error: 47 - .saturating_add(Weight::from_parts(306, 0).saturating_mul(c.into())) + Weight::from_parts(37_090_218, 17022) + // Standard Error: 7 + .saturating_add(Weight::from_parts(317, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -278,9 +278,9 @@ impl WeightInfo for () { // Measured: `754 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(13_631_995, 17022) - // Standard Error: 17 - .saturating_add(Weight::from_parts(305, 0).saturating_mul(c.into())) + Weight::from_parts(12_080_905, 17022) + // Standard Error: 5 + .saturating_add(Weight::from_parts(342, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -293,10 +293,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(23_953_556, 17022) - // Standard Error: 28 - .saturating_add(Weight::from_parts(683, 0).saturating_mul(c.into())) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_615_582, 17022) + // Standard Error: 6 + .saturating_add(Weight::from_parts(526, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -308,8 +308,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `730` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(24_000_000, 17022) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -338,10 +338,10 @@ impl WeightInfo for () { // Estimated: `17022 + i * (16032 ±0)` // Minimum execution time: 22_000_000 picoseconds. Weight::from_parts(23_000_000, 17022) - // Standard Error: 126_726 - .saturating_add(Weight::from_parts(6_633_147, 0).saturating_mul(i.into())) - // Standard Error: 126_726 - .saturating_add(Weight::from_parts(3_973_506, 0).saturating_mul(r.into())) + // Standard Error: 123_314 + .saturating_add(Weight::from_parts(6_491_924, 0).saturating_mul(i.into())) + // Standard Error: 123_314 + .saturating_add(Weight::from_parts(3_850_325, 0).saturating_mul(r.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -360,7 +360,7 @@ impl WeightInfo for () { // Measured: `703` // Estimated: `17022` // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(27_000_000, 17022) + Weight::from_parts(26_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index acb15de3..5b7f9776 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -599,23 +599,34 @@ impl qp_high_security::HighSecurityInspector for HighSec } fn is_whitelisted(call: &RuntimeCall) -> bool { - // Runtime-level whitelist: only reversible-transfers operations allowed #[cfg(feature = "runtime-benchmarks")] { - // For benchmarking: allow system::remark to measure O(c) decode overhead - if matches!(call, RuntimeCall::System(frame_system::Call::remark { .. })) { - return true; - } + // Production whitelist + remark for propose_high_security benchmark + matches!( + call, + RuntimeCall::ReversibleTransfers( + pallet_reversible_transfers::Call::schedule_transfer { .. } + ) | RuntimeCall::ReversibleTransfers( + pallet_reversible_transfers::Call::schedule_asset_transfer { .. } + ) | RuntimeCall::ReversibleTransfers( + pallet_reversible_transfers::Call::cancel { .. } + ) | RuntimeCall::System(frame_system::Call::remark { .. }) + ) } - matches!( - call, - RuntimeCall::ReversibleTransfers( - pallet_reversible_transfers::Call::schedule_transfer { .. } - ) | RuntimeCall::ReversibleTransfers( - pallet_reversible_transfers::Call::schedule_asset_transfer { .. } - ) | RuntimeCall::ReversibleTransfers(pallet_reversible_transfers::Call::cancel { .. }) - ) + #[cfg(not(feature = "runtime-benchmarks"))] + { + matches!( + call, + RuntimeCall::ReversibleTransfers( + pallet_reversible_transfers::Call::schedule_transfer { .. } + ) | RuntimeCall::ReversibleTransfers( + pallet_reversible_transfers::Call::schedule_asset_transfer { .. } + ) | RuntimeCall::ReversibleTransfers( + pallet_reversible_transfers::Call::cancel { .. } + ) + ) + } } fn guardian(who: &AccountId) -> Option { diff --git a/runtime/src/genesis_config_presets.rs b/runtime/src/genesis_config_presets.rs index ff91a2cd..61f71c56 100644 --- a/runtime/src/genesis_config_presets.rs +++ b/runtime/src/genesis_config_presets.rs @@ -83,6 +83,46 @@ pub fn development_config_genesis() -> Value { log::info!("🍆 Endowed account raw: {:?}", account); } + #[cfg(feature = "runtime-benchmarks")] + { + use crate::Runtime; + use frame_benchmarking::v2::{account, whitelisted_caller}; + use pallet_multisig::Pallet as Multisig; + + const SEED: u32 = 0; + let caller = whitelisted_caller::(); + let signer1 = account::("signer1", 0, SEED); + let signer2 = account::("signer2", 1, SEED); + let mut signers = vec![caller, signer1, signer2]; + signers.sort(); + let multisig_address = Multisig::::derive_multisig_address(&signers, 2, 0); + let interceptor = crystal_alice().into_account(); + let delay = 10u32; + + let rt_genesis = pallet_reversible_transfers::GenesisConfig:: { + initial_high_security_accounts: vec![(multisig_address, interceptor, delay)], + }; + + let config = RuntimeGenesisConfig { + balances: BalancesConfig { + balances: endowed_accounts + .iter() + .cloned() + .map(|k| (k, 100_000 * UNIT)) + .chain([( + TreasuryPalletId::get().into_account_truncating(), + 21_000_000 * 30 * UNIT / 100, + )]) + .collect::>(), + }, + sudo: SudoConfig { key: Some(crystal_alice().into_account()) }, + reversible_transfers: rt_genesis, + ..Default::default() + }; + return serde_json::to_value(config).expect("Could not build genesis config."); + } + + #[cfg(not(feature = "runtime-benchmarks"))] genesis_template(endowed_accounts, crystal_alice().into_account()) } diff --git a/runtime/src/transaction_extensions.rs b/runtime/src/transaction_extensions.rs index b26f6a81..63d27871 100644 --- a/runtime/src/transaction_extensions.rs +++ b/runtime/src/transaction_extensions.rs @@ -195,7 +195,11 @@ mod tests { assert_ok!(result); // All other calls are disallowed for high-security accounts - let call = RuntimeCall::System(frame_system::Call::remark { remark: vec![1, 2, 3] }); + // (use transfer_keep_alive - not in whitelist for prod or runtime-benchmarks) + let call = RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { + dest: MultiAddress::Id(bob()), + value: 10 * EXISTENTIAL_DEPOSIT, + }); let result = check_call(call); assert_eq!( result.unwrap_err(), From 4eb15d2ababcc193a898177302f293c4da94071a Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Wed, 11 Feb 2026 17:25:12 +0800 Subject: [PATCH 13/15] feat: Dynamic cleaning methods --- pallets/multisig/src/benchmarking.rs | 20 ++- pallets/multisig/src/lib.rs | 41 ++++-- pallets/multisig/src/weights.rs | 195 ++++++++++++++------------- 3 files changed, 142 insertions(+), 114 deletions(-) diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 2283dbcb..4e17b6b7 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -285,8 +285,11 @@ mod benchmarks { Ok(()) } + /// Benchmark `cancel` extrinsic. Parameter: c = stored proposal call size #[benchmark] - fn cancel() -> Result<(), BenchmarkError> { + fn cancel( + c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, + ) -> Result<(), BenchmarkError> { let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); @@ -296,7 +299,7 @@ mod benchmarks { &multisig_address, 0, &caller, - 10, + c, expiry, &[caller.clone()], ProposalStatus::Active, @@ -311,8 +314,11 @@ mod benchmarks { Ok(()) } + /// Benchmark `remove_expired` extrinsic. Parameter: c = stored proposal call size #[benchmark] - fn remove_expired() -> Result<(), BenchmarkError> { + fn remove_expired( + c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, + ) -> Result<(), BenchmarkError> { let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); @@ -321,7 +327,7 @@ mod benchmarks { &multisig_address, 0, &caller, - 32, + c, expiry, &[caller.clone()], ProposalStatus::Active, @@ -337,11 +343,13 @@ mod benchmarks { } /// Benchmark `claim_deposits` extrinsic. - /// Parameters: i = iterated proposals, r = removed (cleaned) proposals + /// Parameters: i = iterated proposals, r = removed (cleaned) proposals, + /// c = average stored call size (affects iteration cost) #[benchmark] fn claim_deposits( i: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, r: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, + c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { let cleaned_target = (r as u32).min(i); let total_proposals = i; @@ -359,7 +367,7 @@ mod benchmarks { &multisig_address, idx, &caller, - 32, + c, expiry, &[caller.clone()], ProposalStatus::Active, diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index b0836589..fd402501 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -809,7 +809,7 @@ pub mod pallet { /// - `multisig_address`: The multisig account /// - `proposal_id`: ID (nonce) of the proposal to cancel #[pallet::call_index(3)] - #[pallet::weight(::WeightInfo::cancel())] + #[pallet::weight(::WeightInfo::cancel(T::MaxCallSize::get()))] #[allow(clippy::useless_conversion)] pub fn cancel( origin: OriginFor, @@ -842,6 +842,8 @@ pub mod pallet { return Self::err_with_weight(Error::::ProposalNotActive, 1); } + let call_size = proposal.call.len() as u32; + // Remove proposal from storage and return deposit immediately Self::remove_proposal_and_return_deposit( &multisig_address, @@ -857,7 +859,7 @@ pub mod pallet { proposal_id, }); - let actual_weight = ::WeightInfo::cancel(); + let actual_weight = ::WeightInfo::cancel(call_size); Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } @@ -870,7 +872,7 @@ pub mod pallet { /// The deposit is always returned to the original proposer, not the caller. /// This allows any signer to help clean up storage even if proposer is inactive. #[pallet::call_index(4)] - #[pallet::weight(::WeightInfo::remove_expired())] + #[pallet::weight(::WeightInfo::remove_expired(T::MaxCallSize::get()))] pub fn remove_expired( origin: OriginFor, multisig_address: T::AccountId, @@ -925,7 +927,8 @@ pub mod pallet { #[pallet::call_index(5)] #[pallet::weight(::WeightInfo::claim_deposits( T::MaxTotalProposalsInStorage::get(), // Worst-case iterated - T::MaxTotalProposalsInStorage::get().saturating_div(2) // Worst-case cleaned (MaxTotal / 2 signers) + T::MaxTotalProposalsInStorage::get().saturating_div(2), // Worst-case cleaned + T::MaxCallSize::get() // Worst-case avg call size ))] #[allow(clippy::useless_conversion)] pub fn claim_deposits( @@ -935,8 +938,8 @@ pub mod pallet { let caller = ensure_signed(origin)?; // Cleanup ALL caller's expired proposals - // Returns: (cleaned_count, total_proposals_iterated) - let (cleaned, total_proposals_iterated) = + // Returns: (cleaned_count, total_proposals_iterated, total_call_bytes) + let (cleaned, total_proposals_iterated, total_call_bytes) = Self::cleanup_proposer_expired(&multisig_address, &caller, &caller); let deposit_per_proposal = T::ProposalDeposit::get(); @@ -951,12 +954,18 @@ pub mod pallet { multisig_removed: false, }); - // Return actual weight based on proposals iterated and cleaned - // Accurate charging based on actual work performed: - // - total_proposals_iterated: O(N) read cost - // - cleaned: O(M) write cost (where M <= N) - let actual_weight = - ::WeightInfo::claim_deposits(total_proposals_iterated, cleaned); + // Average call size over iterated proposals (for weight) + let avg_call_size = if total_proposals_iterated > 0 { + total_call_bytes / total_proposals_iterated + } else { + 0 + }; + + let actual_weight = ::WeightInfo::claim_deposits( + total_proposals_iterated, + cleaned, + avg_call_size, + ); Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } @@ -1191,13 +1200,16 @@ pub mod pallet { /// - cleaned_count: number of proposals actually removed /// - total_proposals_iterated: total proposals that existed before cleanup (for weight /// calculation) + /// Returns: (cleaned_count, total_proposals_iterated, total_call_bytes) + /// - total_call_bytes: sum of proposal.call.len() over iterated proposals (for weight) fn cleanup_proposer_expired( multisig_address: &T::AccountId, proposer: &T::AccountId, caller: &T::AccountId, - ) -> (u32, u32) { + ) -> (u32, u32, u32) { let current_block = frame_system::Pallet::::block_number(); let mut total_iterated = 0u32; + let mut total_call_bytes = 0u32; // Collect expired proposals to remove // IMPORTANT: We count ALL proposals during iteration (for weight calculation) @@ -1205,6 +1217,7 @@ pub mod pallet { Proposals::::iter_prefix(multisig_address) .filter_map(|(proposal_id, proposal)| { total_iterated += 1; // Count every proposal we iterate through + total_call_bytes += proposal.call.len() as u32; // Only proposer's expired active proposals if proposal.proposer == *proposer && @@ -1237,7 +1250,7 @@ pub mod pallet { }); } - (cleaned, total_iterated) + (cleaned, total_iterated, total_call_bytes) } /// Remove a proposal from storage and return deposit to proposer diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 655bf7cd..16f609bd 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-02-11, STEPS: `20`, REPEAT: `50`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-02-11, STEPS: `10`, REPEAT: `5`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -28,20 +28,13 @@ // ./target/release/quantus-node // benchmark // pallet -// --chain -// dev -// --pallet -// pallet_multisig -// --extrinsic -// * -// --steps -// 20 -// --repeat -// 50 -// --output -// pallets/multisig/src/weights.rs -// --template -// .maintain/frame-weight-template.hbs +// --chain=dev +// --pallet=pallet_multisig +// --extrinsic=* +// --steps=10 +// --repeat=5 +// --output=./pallets/multisig/src/weights.rs +// --template=./.maintain/frame-weight-template.hbs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -59,9 +52,9 @@ pub trait WeightInfo { fn propose_high_security(c: u32, ) -> Weight; fn approve(c: u32, ) -> Weight; fn execute(c: u32, ) -> Weight; - fn cancel() -> Weight; - fn remove_expired() -> Weight; - fn claim_deposits(i: u32, r: u32, ) -> Weight; + fn cancel(c: u32, ) -> Weight; + fn remove_expired(c: u32, ) -> Weight; + fn claim_deposits(i: u32, r: u32, c: u32, ) -> Weight; fn dissolve_multisig() -> Weight; } @@ -75,10 +68,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 188_000_000 picoseconds. - Weight::from_parts(118_428_692, 10377) - // Standard Error: 35_339 - .saturating_add(Weight::from_parts(4_715_022, 0).saturating_mul(s.into())) + // Minimum execution time: 190_000_000 picoseconds. + Weight::from_parts(130_228_737, 10377) + // Standard Error: 173_215 + .saturating_add(Weight::from_parts(4_858_457, 0).saturating_mul(s.into())) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -93,10 +86,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 36_000_000 picoseconds. - Weight::from_parts(37_047_877, 17022) - // Standard Error: 8 - .saturating_add(Weight::from_parts(186, 0).saturating_mul(c.into())) + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(47_272_624, 17022) + // Standard Error: 462 + .saturating_add(Weight::from_parts(321, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -111,10 +104,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 36_000_000 picoseconds. - Weight::from_parts(37_090_218, 17022) - // Standard Error: 7 - .saturating_add(Weight::from_parts(317, 0).saturating_mul(c.into())) + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(52_569_621, 17022) + // Standard Error: 754 + .saturating_add(Weight::from_parts(841, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -127,10 +120,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(12_080_905, 17022) - // Standard Error: 5 - .saturating_add(Weight::from_parts(342, 0).saturating_mul(c.into())) + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(21_709_851, 17022) + // Standard Error: 379 + .saturating_add(Weight::from_parts(964, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -143,10 +136,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_615_582, 17022) - // Standard Error: 6 - .saturating_add(Weight::from_parts(526, 0).saturating_mul(c.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(28_872_924, 17022) + // Standard Error: 490 + .saturating_add(Weight::from_parts(1_109, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -154,12 +147,15 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) - fn cancel() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `730` + // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(25_491_207, 17022) + // Standard Error: 249 + .saturating_add(Weight::from_parts(751, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -167,12 +163,13 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) - fn remove_expired() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn remove_expired(_c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `752` + // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(33_024_117, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -182,19 +179,22 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// The range of component `i` is `[1, 200]`. /// The range of component `r` is `[1, 200]`. - fn claim_deposits(i: u32, r: u32, ) -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn claim_deposits(i: u32, r: u32, _c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `735 + i * (115 ±0)` + // Measured: `747 + c * (1 ±0) + i * (114 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) - // Standard Error: 123_314 - .saturating_add(Weight::from_parts(6_491_924, 0).saturating_mul(i.into())) - // Standard Error: 123_314 - .saturating_add(Weight::from_parts(3_850_325, 0).saturating_mul(r.into())) + // Minimum execution time: 31_000_000 picoseconds. + Weight::from_parts(37_000_000, 17022) + // Standard Error: 492_097 + .saturating_add(Weight::from_parts(11_382_125, 0).saturating_mul(i.into())) + // Standard Error: 492_097 + .saturating_add(Weight::from_parts(5_934_714, 0).saturating_mul(r.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(i.into()))) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(r.into()))) .saturating_add(Weight::from_parts(0, 16032).saturating_mul(i.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) @@ -209,8 +209,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) + // Minimum execution time: 30_000_000 picoseconds. + Weight::from_parts(33_000_000, 17022) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -225,10 +225,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 188_000_000 picoseconds. - Weight::from_parts(118_428_692, 10377) - // Standard Error: 35_339 - .saturating_add(Weight::from_parts(4_715_022, 0).saturating_mul(s.into())) + // Minimum execution time: 190_000_000 picoseconds. + Weight::from_parts(130_228_737, 10377) + // Standard Error: 173_215 + .saturating_add(Weight::from_parts(4_858_457, 0).saturating_mul(s.into())) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -243,10 +243,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 36_000_000 picoseconds. - Weight::from_parts(37_047_877, 17022) - // Standard Error: 8 - .saturating_add(Weight::from_parts(186, 0).saturating_mul(c.into())) + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(47_272_624, 17022) + // Standard Error: 462 + .saturating_add(Weight::from_parts(321, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -261,10 +261,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 36_000_000 picoseconds. - Weight::from_parts(37_090_218, 17022) - // Standard Error: 7 - .saturating_add(Weight::from_parts(317, 0).saturating_mul(c.into())) + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(52_569_621, 17022) + // Standard Error: 754 + .saturating_add(Weight::from_parts(841, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -277,10 +277,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(12_080_905, 17022) - // Standard Error: 5 - .saturating_add(Weight::from_parts(342, 0).saturating_mul(c.into())) + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(21_709_851, 17022) + // Standard Error: 379 + .saturating_add(Weight::from_parts(964, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -293,10 +293,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_615_582, 17022) - // Standard Error: 6 - .saturating_add(Weight::from_parts(526, 0).saturating_mul(c.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(28_872_924, 17022) + // Standard Error: 490 + .saturating_add(Weight::from_parts(1_109, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -304,12 +304,15 @@ impl WeightInfo for () { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) - fn cancel() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `730` + // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(25_491_207, 17022) + // Standard Error: 249 + .saturating_add(Weight::from_parts(751, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -317,12 +320,13 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) - fn remove_expired() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn remove_expired(_c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `752` + // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(33_024_117, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -332,19 +336,22 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// The range of component `i` is `[1, 200]`. /// The range of component `r` is `[1, 200]`. - fn claim_deposits(i: u32, r: u32, ) -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn claim_deposits(i: u32, r: u32, _c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `735 + i * (115 ±0)` + // Measured: `747 + c * (1 ±0) + i * (114 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(23_000_000, 17022) - // Standard Error: 123_314 - .saturating_add(Weight::from_parts(6_491_924, 0).saturating_mul(i.into())) - // Standard Error: 123_314 - .saturating_add(Weight::from_parts(3_850_325, 0).saturating_mul(r.into())) + // Minimum execution time: 31_000_000 picoseconds. + Weight::from_parts(37_000_000, 17022) + // Standard Error: 492_097 + .saturating_add(Weight::from_parts(11_382_125, 0).saturating_mul(i.into())) + // Standard Error: 492_097 + .saturating_add(Weight::from_parts(5_934_714, 0).saturating_mul(r.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(i.into()))) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(r.into()))) .saturating_add(Weight::from_parts(0, 16032).saturating_mul(i.into())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) @@ -359,8 +366,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) + // Minimum execution time: 30_000_000 picoseconds. + Weight::from_parts(33_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } From 9f8e5eccc2d0987810cd5bd04d349e1de89bd7d2 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Wed, 11 Feb 2026 17:44:38 +0800 Subject: [PATCH 14/15] feat: Approve dissolve - two variants --- pallets/multisig/src/benchmarking.rs | 23 ++- pallets/multisig/src/lib.rs | 17 ++- pallets/multisig/src/weights.rs | 212 ++++++++++++++++----------- 3 files changed, 163 insertions(+), 89 deletions(-) diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 4e17b6b7..c7c60f9e 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -385,8 +385,29 @@ mod benchmarks { Ok(()) } + /// Benchmark `approve_dissolve` when threshold is NOT reached. + /// Just adds an approval to DissolveApprovals (cheap path). #[benchmark] - fn dissolve_multisig() -> Result<(), BenchmarkError> { + fn approve_dissolve() -> Result<(), BenchmarkError> { + let (caller, signers) = setup_funded_signer_set::(3); + let threshold = 3u32; // Need 3 approvals, we add 1st + let deposit = T::MultisigDeposit::get(); + ::Currency::reserve(&caller, deposit)?; + + let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 0, 0); + // No pre-inserted approvals - caller adds first approval (threshold not reached) + + #[extrinsic_call] + approve_dissolve(RawOrigin::Signed(caller.clone()), multisig_address.clone()); + + assert!(Multisigs::::contains_key(&multisig_address)); + assert!(DissolveApprovals::::get(&multisig_address).unwrap().len() == 1); + Ok(()) + } + + /// Benchmark `approve_dissolve` when threshold IS reached (dissolves multisig). + #[benchmark] + fn approve_dissolve_threshold_reached() -> Result<(), BenchmarkError> { let (caller, signers) = setup_funded_signer_set::(3); let threshold = 2u32; let deposit = T::MultisigDeposit::get(); diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index fd402501..e8c65568 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -1053,11 +1053,12 @@ pub mod pallet { /// - Deposit is returned to creator /// - Multisig storage is removed #[pallet::call_index(6)] - #[pallet::weight(::WeightInfo::dissolve_multisig())] + #[pallet::weight(::WeightInfo::approve_dissolve_threshold_reached())] + #[allow(clippy::useless_conversion)] pub fn approve_dissolve( origin: OriginFor, multisig_address: T::AccountId, - ) -> DispatchResult { + ) -> DispatchResultWithPostInfo { let approver = ensure_signed(origin)?; // 1. Get multisig data @@ -1090,12 +1091,13 @@ pub mod pallet { // 8. Emit approval event Self::deposit_event(Event::DissolveApproved { multisig_address: multisig_address.clone(), - approver, + approver: approver.clone(), approvals_count, }); // 9. Check if threshold reached - if approvals_count >= multisig_data.threshold { + let threshold_reached = approvals_count >= multisig_data.threshold; + if threshold_reached { // Threshold reached - dissolve multisig let deposit = multisig_data.deposit; let creator = multisig_data.creator.clone(); @@ -1118,7 +1120,12 @@ pub mod pallet { DissolveApprovals::::insert(&multisig_address, approvals); } - Ok(()) + let actual_weight = if threshold_reached { + ::WeightInfo::approve_dissolve_threshold_reached() + } else { + ::WeightInfo::approve_dissolve() + }; + Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } } diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 16f609bd..5027ae80 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-02-11, STEPS: `10`, REPEAT: `5`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-02-11, STEPS: `20`, REPEAT: `50`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -28,13 +28,20 @@ // ./target/release/quantus-node // benchmark // pallet -// --chain=dev -// --pallet=pallet_multisig -// --extrinsic=* -// --steps=10 -// --repeat=5 -// --output=./pallets/multisig/src/weights.rs -// --template=./.maintain/frame-weight-template.hbs +// --chain +// dev +// --pallet +// pallet_multisig +// --extrinsic +// * +// --steps +// 20 +// --repeat +// 50 +// --output +// pallets/multisig/src/weights.rs +// --template +// .maintain/frame-weight-template.hbs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -55,7 +62,8 @@ pub trait WeightInfo { fn cancel(c: u32, ) -> Weight; fn remove_expired(c: u32, ) -> Weight; fn claim_deposits(i: u32, r: u32, c: u32, ) -> Weight; - fn dissolve_multisig() -> Weight; + fn approve_dissolve() -> Weight; + fn approve_dissolve_threshold_reached() -> Weight; } /// Weights for `pallet_multisig` using the Substrate node and recommended hardware. @@ -68,10 +76,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(130_228_737, 10377) - // Standard Error: 173_215 - .saturating_add(Weight::from_parts(4_858_457, 0).saturating_mul(s.into())) + // Minimum execution time: 187_000_000 picoseconds. + Weight::from_parts(119_654_678, 10377) + // Standard Error: 35_456 + .saturating_add(Weight::from_parts(4_781_159, 0).saturating_mul(s.into())) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -86,10 +94,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 38_000_000 picoseconds. - Weight::from_parts(47_272_624, 17022) - // Standard Error: 462 - .saturating_add(Weight::from_parts(321, 0).saturating_mul(c.into())) + // Minimum execution time: 35_000_000 picoseconds. + Weight::from_parts(38_064_603, 17022) + // Standard Error: 51 + .saturating_add(Weight::from_parts(647, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -104,10 +112,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 38_000_000 picoseconds. - Weight::from_parts(52_569_621, 17022) - // Standard Error: 754 - .saturating_add(Weight::from_parts(841, 0).saturating_mul(c.into())) + // Minimum execution time: 36_000_000 picoseconds. + Weight::from_parts(41_595_027, 17022) + // Standard Error: 42 + .saturating_add(Weight::from_parts(77, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -120,10 +128,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(21_709_851, 17022) - // Standard Error: 379 - .saturating_add(Weight::from_parts(964, 0).saturating_mul(c.into())) + // Minimum execution time: 11_000_000 picoseconds. + Weight::from_parts(11_717_815, 17022) + // Standard Error: 13 + .saturating_add(Weight::from_parts(533, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -136,10 +144,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(28_872_924, 17022) - // Standard Error: 490 - .saturating_add(Weight::from_parts(1_109, 0).saturating_mul(c.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(24_474_844, 17022) + // Standard Error: 19 + .saturating_add(Weight::from_parts(347, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -152,10 +160,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(25_491_207, 17022) - // Standard Error: 249 - .saturating_add(Weight::from_parts(751, 0).saturating_mul(c.into())) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_775_901, 17022) + // Standard Error: 17 + .saturating_add(Weight::from_parts(186, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -164,12 +172,14 @@ impl WeightInfo for SubstrateWeight { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn remove_expired(_c: u32, ) -> Weight { + fn remove_expired(c: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(33_024_117, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_133_649, 17022) + // Standard Error: 16 + .saturating_add(Weight::from_parts(321, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -182,14 +192,14 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. fn claim_deposits(i: u32, r: u32, _c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `747 + c * (1 ±0) + i * (114 ±0)` + // Measured: `728 + c * (1 ±0) + i * (115 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 31_000_000 picoseconds. - Weight::from_parts(37_000_000, 17022) - // Standard Error: 492_097 - .saturating_add(Weight::from_parts(11_382_125, 0).saturating_mul(i.into())) - // Standard Error: 492_097 - .saturating_add(Weight::from_parts(5_934_714, 0).saturating_mul(r.into())) + // Minimum execution time: 26_000_000 picoseconds. + Weight::from_parts(26_000_000, 17022) + // Standard Error: 106_875 + .saturating_add(Weight::from_parts(11_054_346, 0).saturating_mul(i.into())) + // Standard Error: 106_875 + .saturating_add(Weight::from_parts(5_462_826, 0).saturating_mul(r.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -197,6 +207,23 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(r.into()))) .saturating_add(Weight::from_parts(0, 16032).saturating_mul(i.into())) } + /// Storage: `Multisig::Multisigs` (r:1 w:0) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:0) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:0) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Multisig::DissolveApprovals` (r:1 w:1) + /// Proof: `Multisig::DissolveApprovals` (`max_values`: None, `max_size`: Some(3250), added: 5725, mode: `MaxEncodedLen`) + fn approve_dissolve() -> Weight { + // Proof Size summary in bytes: + // Measured: `526` + // Estimated: `17022` + // Minimum execution time: 14_000_000 picoseconds. + Weight::from_parts(20_000_000, 17022) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:0) @@ -205,12 +232,12 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// Storage: `Multisig::DissolveApprovals` (r:1 w:1) /// Proof: `Multisig::DissolveApprovals` (`max_values`: None, `max_size`: Some(3250), added: 5725, mode: `MaxEncodedLen`) - fn dissolve_multisig() -> Weight { + fn approve_dissolve_threshold_reached() -> Weight { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 30_000_000 picoseconds. - Weight::from_parts(33_000_000, 17022) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(26_000_000, 17022) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -225,10 +252,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(130_228_737, 10377) - // Standard Error: 173_215 - .saturating_add(Weight::from_parts(4_858_457, 0).saturating_mul(s.into())) + // Minimum execution time: 187_000_000 picoseconds. + Weight::from_parts(119_654_678, 10377) + // Standard Error: 35_456 + .saturating_add(Weight::from_parts(4_781_159, 0).saturating_mul(s.into())) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -243,10 +270,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 38_000_000 picoseconds. - Weight::from_parts(47_272_624, 17022) - // Standard Error: 462 - .saturating_add(Weight::from_parts(321, 0).saturating_mul(c.into())) + // Minimum execution time: 35_000_000 picoseconds. + Weight::from_parts(38_064_603, 17022) + // Standard Error: 51 + .saturating_add(Weight::from_parts(647, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -261,10 +288,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 38_000_000 picoseconds. - Weight::from_parts(52_569_621, 17022) - // Standard Error: 754 - .saturating_add(Weight::from_parts(841, 0).saturating_mul(c.into())) + // Minimum execution time: 36_000_000 picoseconds. + Weight::from_parts(41_595_027, 17022) + // Standard Error: 42 + .saturating_add(Weight::from_parts(77, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -277,10 +304,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(21_709_851, 17022) - // Standard Error: 379 - .saturating_add(Weight::from_parts(964, 0).saturating_mul(c.into())) + // Minimum execution time: 11_000_000 picoseconds. + Weight::from_parts(11_717_815, 17022) + // Standard Error: 13 + .saturating_add(Weight::from_parts(533, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -293,10 +320,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `754 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(28_872_924, 17022) - // Standard Error: 490 - .saturating_add(Weight::from_parts(1_109, 0).saturating_mul(c.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(24_474_844, 17022) + // Standard Error: 19 + .saturating_add(Weight::from_parts(347, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -309,10 +336,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(25_491_207, 17022) - // Standard Error: 249 - .saturating_add(Weight::from_parts(751, 0).saturating_mul(c.into())) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_775_901, 17022) + // Standard Error: 17 + .saturating_add(Weight::from_parts(186, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -321,12 +348,14 @@ impl WeightInfo for () { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn remove_expired(_c: u32, ) -> Weight { + fn remove_expired(c: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `722 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(33_024_117, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_133_649, 17022) + // Standard Error: 16 + .saturating_add(Weight::from_parts(321, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -339,14 +368,14 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. fn claim_deposits(i: u32, r: u32, _c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `747 + c * (1 ±0) + i * (114 ±0)` + // Measured: `728 + c * (1 ±0) + i * (115 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 31_000_000 picoseconds. - Weight::from_parts(37_000_000, 17022) - // Standard Error: 492_097 - .saturating_add(Weight::from_parts(11_382_125, 0).saturating_mul(i.into())) - // Standard Error: 492_097 - .saturating_add(Weight::from_parts(5_934_714, 0).saturating_mul(r.into())) + // Minimum execution time: 26_000_000 picoseconds. + Weight::from_parts(26_000_000, 17022) + // Standard Error: 106_875 + .saturating_add(Weight::from_parts(11_054_346, 0).saturating_mul(i.into())) + // Standard Error: 106_875 + .saturating_add(Weight::from_parts(5_462_826, 0).saturating_mul(r.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -354,6 +383,23 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(r.into()))) .saturating_add(Weight::from_parts(0, 16032).saturating_mul(i.into())) } + /// Storage: `Multisig::Multisigs` (r:1 w:0) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:0) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:0) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Multisig::DissolveApprovals` (r:1 w:1) + /// Proof: `Multisig::DissolveApprovals` (`max_values`: None, `max_size`: Some(3250), added: 5725, mode: `MaxEncodedLen`) + fn approve_dissolve() -> Weight { + // Proof Size summary in bytes: + // Measured: `526` + // Estimated: `17022` + // Minimum execution time: 14_000_000 picoseconds. + Weight::from_parts(20_000_000, 17022) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6912), added: 9387, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:0) @@ -362,12 +408,12 @@ impl WeightInfo for () { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// Storage: `Multisig::DissolveApprovals` (r:1 w:1) /// Proof: `Multisig::DissolveApprovals` (`max_values`: None, `max_size`: Some(3250), added: 5725, mode: `MaxEncodedLen`) - fn dissolve_multisig() -> Weight { + fn approve_dissolve_threshold_reached() -> Weight { // Proof Size summary in bytes: // Measured: `703` // Estimated: `17022` - // Minimum execution time: 30_000_000 picoseconds. - Weight::from_parts(33_000_000, 17022) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(26_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } From c68cc83ffd3cbc2c0f6f116ec4653264354b60b4 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Wed, 11 Feb 2026 18:12:45 +0800 Subject: [PATCH 15/15] feat: Approval with negative weight --- pallets/multisig/src/benchmarking.rs | 55 +++++---- pallets/multisig/src/weights.rs | 164 +++++++++++++-------------- 2 files changed, 115 insertions(+), 104 deletions(-) diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index c7c60f9e..59f45386 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -107,6 +107,11 @@ mod benchmarks { frame_system::Pallet::::set_block_number(n.into()); } + /// Returns a Vec of MaxSigners account IDs for worst-case approvals decode cost. + fn approvals_max() -> Vec { + (0..T::MaxSigners::get()).map(|i| account("approval", i, SEED)).collect() + } + /// Insert a single proposal into storage. `approvals` = list of account ids that have approved. fn insert_proposal( multisig_address: &T::AccountId, @@ -223,60 +228,65 @@ mod benchmarks { Ok(()) } - /// Benchmark `approve` extrinsic (without execution). Threshold 3, so 1 approval added → 2/3. + /// Benchmark `approve` extrinsic (without execution). Uses MaxSigners for worst-case approvals + /// decode. Threshold = MaxSigners, 99 approvals pre-stored, approver adds 100th. /// Parameter: c = call size (stored proposal call) #[benchmark] fn approve( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { - let (caller, signers) = setup_funded_signer_set::(4); // caller + 3 signers - let threshold = 3u32; + let max_s = T::MaxSigners::get(); + let (caller, signers) = setup_funded_signer_set::(max_s); + let threshold = max_s; let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); set_block::(100); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); + // Worst-case approvals decode: threshold-1 approvals (99 for MaxSigners=100) + let approvals: Vec<_> = signers[0..threshold as usize - 1].to_vec(); insert_proposal::( &multisig_address, 0, &caller, c, expiry, - &[caller.clone()], + &approvals, ProposalStatus::Active, 10u32.into(), ); - let signer1 = signers[1].clone(); + let approver = signers[threshold as usize - 1].clone(); #[extrinsic_call] - _(RawOrigin::Signed(signer1), multisig_address.clone(), 0u32); + _(RawOrigin::Signed(approver), multisig_address.clone(), 0u32); let proposal = Proposals::::get(&multisig_address, 0).unwrap(); - assert!(proposal.approvals.len() == 2); + assert_eq!(proposal.approvals.len(), threshold as usize); Ok(()) } /// Benchmark `execute` extrinsic (dispatches an Approved proposal). - /// Parameter: c = call size + /// Uses MaxSigners approvals for worst-case decode. Parameter: c = call size #[benchmark] fn execute( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, ) -> Result<(), BenchmarkError> { - let (caller, signers) = setup_funded_signer_set::(3); - let threshold = 2u32; + let max_s = T::MaxSigners::get(); + let (caller, signers) = setup_funded_signer_set::(max_s); + let threshold = max_s; let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); set_block::(100); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); - // Approved = caller + signers[1] (2/2) + // Worst-case approvals decode: MaxSigners approvals (Approved) insert_proposal::( &multisig_address, 0, &caller, c, expiry, - &[caller.clone(), signers[1].clone()], + &signers, ProposalStatus::Approved, 10u32.into(), ); - let executor = signers[2].clone(); + let executor = signers[0].clone(); #[extrinsic_call] _(RawOrigin::Signed(executor), multisig_address.clone(), 0u32); @@ -285,7 +295,8 @@ mod benchmarks { Ok(()) } - /// Benchmark `cancel` extrinsic. Parameter: c = stored proposal call size + /// Benchmark `cancel` extrinsic. Uses MaxSigners approvals for worst-case decode. + /// Parameter: c = stored proposal call size #[benchmark] fn cancel( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, @@ -295,13 +306,14 @@ mod benchmarks { let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); set_block::(100); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); + let approvals = approvals_max::(); insert_proposal::( &multisig_address, 0, &caller, c, expiry, - &[caller.clone()], + &approvals, ProposalStatus::Active, T::ProposalDeposit::get(), ); @@ -314,7 +326,8 @@ mod benchmarks { Ok(()) } - /// Benchmark `remove_expired` extrinsic. Parameter: c = stored proposal call size + /// Benchmark `remove_expired` extrinsic. Uses MaxSigners approvals for worst-case decode. + /// Parameter: c = stored proposal call size #[benchmark] fn remove_expired( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, @@ -323,13 +336,14 @@ mod benchmarks { let threshold = 2u32; let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, 1, 1); let expiry = 10u32.into(); + let approvals = approvals_max::(); insert_proposal::( &multisig_address, 0, &caller, c, expiry, - &[caller.clone()], + &approvals, ProposalStatus::Active, 10u32.into(), ); @@ -342,8 +356,8 @@ mod benchmarks { Ok(()) } - /// Benchmark `claim_deposits` extrinsic. - /// Parameters: i = iterated proposals, r = removed (cleaned) proposals, + /// Benchmark `claim_deposits` extrinsic. Uses MaxSigners approvals per proposal for worst-case + /// decode. Parameters: i = iterated proposals, r = removed (cleaned) proposals, /// c = average stored call size (affects iteration cost) #[benchmark] fn claim_deposits( @@ -359,6 +373,7 @@ mod benchmarks { let multisig_address = insert_multisig::(&caller, &signers, threshold, 0, total_proposals, total_proposals); + let approvals = approvals_max::(); let expired_block = 10u32.into(); let future_block = 999999u32.into(); for idx in 0..total_proposals { @@ -369,7 +384,7 @@ mod benchmarks { &caller, c, expiry, - &[caller.clone()], + &approvals, ProposalStatus::Active, 10u32.into(), ); diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 5027ae80..e5732fe6 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -76,10 +76,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 187_000_000 picoseconds. - Weight::from_parts(119_654_678, 10377) - // Standard Error: 35_456 - .saturating_add(Weight::from_parts(4_781_159, 0).saturating_mul(s.into())) + // Minimum execution time: 189_000_000 picoseconds. + Weight::from_parts(120_513_891, 10377) + // Standard Error: 35_600 + .saturating_add(Weight::from_parts(4_800_379, 0).saturating_mul(s.into())) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -94,10 +94,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 35_000_000 picoseconds. - Weight::from_parts(38_064_603, 17022) - // Standard Error: 51 - .saturating_add(Weight::from_parts(647, 0).saturating_mul(c.into())) + // Minimum execution time: 36_000_000 picoseconds. + Weight::from_parts(37_772_595, 17022) + // Standard Error: 7 + .saturating_add(Weight::from_parts(172, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -113,9 +113,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `838` // Estimated: `17022` // Minimum execution time: 36_000_000 picoseconds. - Weight::from_parts(41_595_027, 17022) - // Standard Error: 42 - .saturating_add(Weight::from_parts(77, 0).saturating_mul(c.into())) + Weight::from_parts(38_070_055, 17022) + // Standard Error: 57 + .saturating_add(Weight::from_parts(672, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -124,14 +124,12 @@ impl WeightInfo for SubstrateWeight { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn approve(c: u32, ) -> Weight { + fn approve(_c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `754 + c * (1 ±0)` + // Measured: `6964 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(11_717_815, 17022) - // Standard Error: 13 - .saturating_add(Weight::from_parts(533, 0).saturating_mul(c.into())) + // Minimum execution time: 17_000_000 picoseconds. + Weight::from_parts(33_776_726, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -142,12 +140,12 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. fn execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `754 + c * (1 ±0)` + // Measured: `6996 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(24_474_844, 17022) - // Standard Error: 19 - .saturating_add(Weight::from_parts(347, 0).saturating_mul(c.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(43_059_043, 17022) + // Standard Error: 99 + .saturating_add(Weight::from_parts(890, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -158,12 +156,12 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `722 + c * (1 ±0)` + // Measured: `3891 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_775_901, 17022) - // Standard Error: 17 - .saturating_add(Weight::from_parts(186, 0).saturating_mul(c.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(25_331_594, 17022) + // Standard Error: 38 + .saturating_add(Weight::from_parts(451, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -174,12 +172,12 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. fn remove_expired(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `722 + c * (1 ±0)` + // Measured: `3891 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_133_649, 17022) - // Standard Error: 16 - .saturating_add(Weight::from_parts(321, 0).saturating_mul(c.into())) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(25_274_827, 17022) + // Standard Error: 49 + .saturating_add(Weight::from_parts(425, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -192,14 +190,14 @@ impl WeightInfo for SubstrateWeight { /// The range of component `c` is `[0, 10140]`. fn claim_deposits(i: u32, r: u32, _c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `728 + c * (1 ±0) + i * (115 ±0)` + // Measured: `3897 + c * (1 ±0) + i * (115 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 26_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) - // Standard Error: 106_875 - .saturating_add(Weight::from_parts(11_054_346, 0).saturating_mul(i.into())) - // Standard Error: 106_875 - .saturating_add(Weight::from_parts(5_462_826, 0).saturating_mul(r.into())) + // Minimum execution time: 27_000_000 picoseconds. + Weight::from_parts(30_000_000, 17022) + // Standard Error: 102_732 + .saturating_add(Weight::from_parts(11_666_897, 0).saturating_mul(i.into())) + // Standard Error: 102_732 + .saturating_add(Weight::from_parts(5_671_417, 0).saturating_mul(r.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -219,8 +217,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `526` // Estimated: `17022` - // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(20_000_000, 17022) + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(16_000_000, 17022) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -237,7 +235,7 @@ impl WeightInfo for SubstrateWeight { // Measured: `703` // Estimated: `17022` // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) + Weight::from_parts(28_000_000, 17022) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -252,10 +250,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10377` - // Minimum execution time: 187_000_000 picoseconds. - Weight::from_parts(119_654_678, 10377) - // Standard Error: 35_456 - .saturating_add(Weight::from_parts(4_781_159, 0).saturating_mul(s.into())) + // Minimum execution time: 189_000_000 picoseconds. + Weight::from_parts(120_513_891, 10377) + // Standard Error: 35_600 + .saturating_add(Weight::from_parts(4_800_379, 0).saturating_mul(s.into())) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -270,10 +268,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `838` // Estimated: `17022` - // Minimum execution time: 35_000_000 picoseconds. - Weight::from_parts(38_064_603, 17022) - // Standard Error: 51 - .saturating_add(Weight::from_parts(647, 0).saturating_mul(c.into())) + // Minimum execution time: 36_000_000 picoseconds. + Weight::from_parts(37_772_595, 17022) + // Standard Error: 7 + .saturating_add(Weight::from_parts(172, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -289,9 +287,9 @@ impl WeightInfo for () { // Measured: `838` // Estimated: `17022` // Minimum execution time: 36_000_000 picoseconds. - Weight::from_parts(41_595_027, 17022) - // Standard Error: 42 - .saturating_add(Weight::from_parts(77, 0).saturating_mul(c.into())) + Weight::from_parts(38_070_055, 17022) + // Standard Error: 57 + .saturating_add(Weight::from_parts(672, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -300,14 +298,12 @@ impl WeightInfo for () { /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. - fn approve(c: u32, ) -> Weight { + fn approve(_c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `754 + c * (1 ±0)` + // Measured: `6964 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(11_717_815, 17022) - // Standard Error: 13 - .saturating_add(Weight::from_parts(533, 0).saturating_mul(c.into())) + // Minimum execution time: 17_000_000 picoseconds. + Weight::from_parts(33_776_726, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -318,12 +314,12 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. fn execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `754 + c * (1 ±0)` + // Measured: `6996 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(24_474_844, 17022) - // Standard Error: 19 - .saturating_add(Weight::from_parts(347, 0).saturating_mul(c.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(43_059_043, 17022) + // Standard Error: 99 + .saturating_add(Weight::from_parts(890, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -334,12 +330,12 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `722 + c * (1 ±0)` + // Measured: `3891 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 21_000_000 picoseconds. - Weight::from_parts(22_775_901, 17022) - // Standard Error: 17 - .saturating_add(Weight::from_parts(186, 0).saturating_mul(c.into())) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(25_331_594, 17022) + // Standard Error: 38 + .saturating_add(Weight::from_parts(451, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -350,12 +346,12 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. fn remove_expired(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `722 + c * (1 ±0)` + // Measured: `3891 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_133_649, 17022) - // Standard Error: 16 - .saturating_add(Weight::from_parts(321, 0).saturating_mul(c.into())) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(25_274_827, 17022) + // Standard Error: 49 + .saturating_add(Weight::from_parts(425, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -368,14 +364,14 @@ impl WeightInfo for () { /// The range of component `c` is `[0, 10140]`. fn claim_deposits(i: u32, r: u32, _c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `728 + c * (1 ±0) + i * (115 ±0)` + // Measured: `3897 + c * (1 ±0) + i * (115 ±0)` // Estimated: `17022 + i * (16032 ±0)` - // Minimum execution time: 26_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) - // Standard Error: 106_875 - .saturating_add(Weight::from_parts(11_054_346, 0).saturating_mul(i.into())) - // Standard Error: 106_875 - .saturating_add(Weight::from_parts(5_462_826, 0).saturating_mul(r.into())) + // Minimum execution time: 27_000_000 picoseconds. + Weight::from_parts(30_000_000, 17022) + // Standard Error: 102_732 + .saturating_add(Weight::from_parts(11_666_897, 0).saturating_mul(i.into())) + // Standard Error: 102_732 + .saturating_add(Weight::from_parts(5_671_417, 0).saturating_mul(r.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(i.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -395,8 +391,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `526` // Estimated: `17022` - // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(20_000_000, 17022) + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(16_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -413,7 +409,7 @@ impl WeightInfo for () { // Measured: `703` // Estimated: `17022` // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(26_000_000, 17022) + Weight::from_parts(28_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) }