From d1a26a12bc5851b7744dc5b42942e9262e5e37e3 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Mon, 12 Jan 2026 09:06:23 +0800 Subject: [PATCH 01/27] feat: Merkle Airdrop - removed --- Cargo.lock | 20 - Cargo.toml | 2 - pallets/merkle-airdrop/Cargo.toml | 63 --- pallets/merkle-airdrop/README.md | 14 - pallets/merkle-airdrop/src/benchmarking.rs | 196 ------- pallets/merkle-airdrop/src/lib.rs | 584 -------------------- pallets/merkle-airdrop/src/mock.rs | 129 ----- pallets/merkle-airdrop/src/tests.rs | 591 --------------------- pallets/merkle-airdrop/src/weights.rs | 193 ------- runtime/Cargo.toml | 3 - runtime/src/benchmarks.rs | 1 - runtime/src/configs/mod.rs | 16 - runtime/src/lib.rs | 4 - 13 files changed, 1816 deletions(-) delete mode 100644 pallets/merkle-airdrop/Cargo.toml delete mode 100644 pallets/merkle-airdrop/README.md delete mode 100644 pallets/merkle-airdrop/src/benchmarking.rs delete mode 100644 pallets/merkle-airdrop/src/lib.rs delete mode 100644 pallets/merkle-airdrop/src/mock.rs delete mode 100644 pallets/merkle-airdrop/src/tests.rs delete mode 100644 pallets/merkle-airdrop/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index 35560863..ef334b1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7119,25 +7119,6 @@ dependencies = [ "sp-runtime", ] -[[package]] -name = "pallet-merkle-airdrop" -version = "0.1.0" -dependencies = [ - "binary-merkle-tree", - "frame-benchmarking", - "frame-support", - "frame-system", - "log", - "pallet-balances 40.0.1", - "pallet-vesting", - "parity-scale-codec", - "scale-info", - "sha2 0.10.9", - "sp-core", - "sp-io", - "sp-runtime", -] - [[package]] name = "pallet-message-queue" version = "44.0.0" @@ -9191,7 +9172,6 @@ dependencies = [ "pallet-assets-holder", "pallet-balances 40.0.1", "pallet-conviction-voting", - "pallet-merkle-airdrop", "pallet-mining-rewards", "pallet-preimage", "pallet-qpow", diff --git a/Cargo.toml b/Cargo.toml index b6c3abde..32a7f406 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,6 @@ members = [ "miner-api", "node", "pallets/balances", - "pallets/merkle-airdrop", "pallets/mining-rewards", "pallets/qpow", "pallets/reversible-transfers", @@ -131,7 +130,6 @@ zeroize = { version = "1.7.0", default-features = false } # Own dependencies pallet-balances = { path = "./pallets/balances", default-features = false } -pallet-merkle-airdrop = { path = "./pallets/merkle-airdrop", default-features = false } pallet-mining-rewards = { path = "./pallets/mining-rewards", default-features = false } pallet-qpow = { path = "./pallets/qpow", default-features = false } pallet-reversible-transfers = { path = "./pallets/reversible-transfers", default-features = false } diff --git a/pallets/merkle-airdrop/Cargo.toml b/pallets/merkle-airdrop/Cargo.toml deleted file mode 100644 index 3c88c4bb..00000000 --- a/pallets/merkle-airdrop/Cargo.toml +++ /dev/null @@ -1,63 +0,0 @@ -[package] -authors.workspace = true -description = "A pallet for distributing tokens via Merkle proofs" -edition.workspace = true -homepage.workspace = true -license = "MIT-0" -name = "pallet-merkle-airdrop" -publish = false -repository.workspace = true -version = "0.1.0" - -[package.metadata.docs.rs] -targets = ["x86_64-unknown-linux-gnu"] - -[dependencies] -binary-merkle-tree.workspace = true -codec = { workspace = true, default-features = false, features = ["derive"] } -frame-benchmarking = { optional = true, workspace = true } -frame-support.workspace = true -frame-system.workspace = true -log.workspace = true -pallet-vesting = { workspace = true, optional = true } -scale-info = { workspace = true, default-features = false, features = ["derive"] } -sha2.workspace = true -sp-core.workspace = true -sp-io.workspace = true -sp-runtime.workspace = true - -[dev-dependencies] -pallet-balances.features = ["std"] -pallet-balances.workspace = true -pallet-vesting.workspace = true -sp-core.workspace = true -sp-io.workspace = true -sp-runtime.workspace = true - -[features] -default = ["std"] -runtime-benchmarks = [ - "frame-benchmarking/runtime-benchmarks", - "frame-support/runtime-benchmarks", - "frame-system/runtime-benchmarks", - "pallet-vesting", -] -std = [ - "binary-merkle-tree/std", - "codec/std", - "frame-benchmarking?/std", - "frame-support/std", - "frame-system/std", - "log/std", - "pallet-balances/std", - "pallet-vesting?/std", - "scale-info/std", - "sha2/std", - "sp-core/std", - "sp-io/std", - "sp-runtime/std", -] -try-runtime = [ - "frame-support/try-runtime", - "frame-system/try-runtime", -] diff --git a/pallets/merkle-airdrop/README.md b/pallets/merkle-airdrop/README.md deleted file mode 100644 index 2e8495fb..00000000 --- a/pallets/merkle-airdrop/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Merkle Airdrop Pallet - -A Substrate pallet for distributing tokens via Merkle proofs with optional vesting of the airdropped tokens. - -## Testing & Usage - -For testing and interacting with this pallet, please refer to the CLI tool and example in the [resonance-api-client](https://github.com/Quantus-Network/resonance-api-client/blob/master/examples/async/examples/merkle-airdrop-README.md) repository: -- `examples/ac-examples-async/examples/merkle_airdrop_cli.rs` -- `examples/ac-examples-async/examples/merkle_airdrop_cli-README.md` for the documentation - -These tool demonstrates how to: -- Generate Merkle trees and proofs -- Create and fund airdrops -- Claim tokens using proofs diff --git a/pallets/merkle-airdrop/src/benchmarking.rs b/pallets/merkle-airdrop/src/benchmarking.rs deleted file mode 100644 index d965bd8d..00000000 --- a/pallets/merkle-airdrop/src/benchmarking.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Benchmarking setup for pallet-merkle-airdrop - -extern crate alloc; - -use super::*; -use crate::Pallet as MerkleAirdrop; -use frame_benchmarking::v2::*; -use frame_support::BoundedVec; -use frame_system::RawOrigin; -use sp_io::hashing::blake2_256; -use sp_runtime::traits::{Get, Saturating}; - -// Helper function to mirror pallet's Merkle proof verification logic -fn calculate_expected_root_for_benchmark( - initial_leaf_hash: MerkleHash, - proof_elements: &[MerkleHash], -) -> MerkleHash { - let mut computed_hash = initial_leaf_hash; - for proof_element in proof_elements.iter() { - // The comparison logic must match how MerkleHash is ordered in your pallet - if computed_hash.as_ref() < proof_element.as_ref() { - // This replicates Self::calculate_parent_hash_blake2(&computed_hash, proof_element) - let mut combined_data = computed_hash.as_ref().to_vec(); - combined_data.extend_from_slice(proof_element.as_ref()); - computed_hash = blake2_256(&combined_data); - } else { - // This replicates Self::calculate_parent_hash_blake2(proof_element, &computed_hash) - let mut combined_data = proof_element.as_ref().to_vec(); - combined_data.extend_from_slice(computed_hash.as_ref()); - computed_hash = blake2_256(&combined_data); - } - } - computed_hash -} - -#[benchmarks( - where - T: Send + Sync, - T: Config + pallet_vesting::Config>, -)] -mod benchmarks { - use super::*; - - #[benchmark] - fn create_airdrop() { - let caller: T::AccountId = whitelisted_caller(); - let merkle_root = [0u8; 32]; - let vesting_period = None; - let vesting_schedule = None; - - #[extrinsic_call] - create_airdrop(RawOrigin::Signed(caller), merkle_root, vesting_period, vesting_schedule); - } - - #[benchmark] - fn fund_airdrop() { - let caller: T::AccountId = whitelisted_caller(); - let merkle_root = [0u8; 32]; - - let airdrop_id = MerkleAirdrop::::next_airdrop_id(); - AirdropInfo::::insert( - airdrop_id, - AirdropMetadata { - merkle_root, - balance: 0u32.into(), - creator: caller.clone(), - vesting_period: None, - vesting_delay: None, - }, - ); - - NextAirdropId::::put(airdrop_id + 1); - - let amount: BalanceOf = ::MinVestedTransfer::get(); - - // Get ED and ensure caller has sufficient balance - let ed = CurrencyOf::::minimum_balance(); - - let caller_balance = ed.saturating_mul(10u32.into()).saturating_add(amount); - CurrencyOf::::make_free_balance_be(&caller, caller_balance); - - CurrencyOf::::make_free_balance_be(&MerkleAirdrop::::account_id(), ed); - - #[extrinsic_call] - fund_airdrop(RawOrigin::Signed(caller), airdrop_id, amount); - } - - #[benchmark] - fn claim(p: Linear<0, { T::MaxProofs::get() }>) { - let caller: T::AccountId = whitelisted_caller(); - let recipient: T::AccountId = account("recipient", 0, 0); - - let amount: BalanceOf = ::MinVestedTransfer::get(); - - // 1. Calculate the initial leaf hash - let leaf_hash = MerkleAirdrop::::calculate_leaf_hash_blake2(&recipient, amount); - - // 2. Generate `p` dummy proof elements that will be passed to the extrinsic - let proof_elements_for_extrinsic: alloc::vec::Vec = (0..p) - .map(|i| { - let mut dummy_data = [0u8; 32]; - dummy_data[0] = i as u8; // Make them slightly different for each proof element - blake2_256(&dummy_data) // Hash it to make it a valid MerkleHash type - }) - .collect(); - - let merkle_root_to_store = - calculate_expected_root_for_benchmark(leaf_hash, &proof_elements_for_extrinsic); - - let airdrop_id = MerkleAirdrop::::next_airdrop_id(); - - AirdropInfo::::insert( - airdrop_id, - AirdropMetadata { - merkle_root: merkle_root_to_store, - balance: amount.saturating_mul(2u32.into()), // Ensure enough balance for the claim - creator: caller.clone(), - vesting_period: None, // Simplest case: no vesting period - vesting_delay: None, // Simplest case: no vesting delay - }, - ); - - let large_balance = - amount.saturating_mul(T::MaxProofs::get().into()).saturating_add(amount); - - // Creator might not be strictly needed for `claim` from `None` origin, but good practice - CurrencyOf::::make_free_balance_be(&caller, large_balance); - // Recipient starts with minimal balance or nothing, will receive the airdrop - CurrencyOf::::make_free_balance_be(&recipient, amount); - // Pallet's account needs funds to make the transfer - CurrencyOf::::make_free_balance_be( - &MerkleAirdrop::::account_id(), - large_balance, // Pallet account needs enough to cover the claim - ); - - AirdropInfo::::mutate(airdrop_id, |maybe_info| { - if let Some(info) = maybe_info { - info.balance = large_balance; - } - }); - - // Prepare the Merkle proof argument for the extrinsic call - let merkle_proof_arg = - BoundedVec::::try_from(proof_elements_for_extrinsic) - .expect("Proof elements vector should fit into BoundedVec"); - - // Ensure recipient hasn't claimed yet (benchmark state should be clean) - assert!(!Claimed::::contains_key(airdrop_id, &recipient)); - - #[extrinsic_call] - claim(RawOrigin::None, airdrop_id, recipient.clone(), amount, merkle_proof_arg); - - // Verify successful claim - assert!(Claimed::::contains_key(airdrop_id, &recipient)); - } - - #[benchmark] - fn delete_airdrop() { - let caller: T::AccountId = whitelisted_caller(); - let merkle_root = [0u8; 32]; - - // Create an airdrop first - let airdrop_id = MerkleAirdrop::::next_airdrop_id(); - - AirdropInfo::::insert( - airdrop_id, - AirdropMetadata { - merkle_root, - balance: 0u32.into(), - creator: caller.clone(), - vesting_period: None, - vesting_delay: None, - }, - ); - - NextAirdropId::::put(airdrop_id + 1); - - let ed = CurrencyOf::::minimum_balance(); - let tiny_amount: BalanceOf = 1u32.into(); - let large_balance = ed.saturating_mul(1_000_000u32.into()); - - CurrencyOf::::make_free_balance_be(&caller, large_balance); - CurrencyOf::::make_free_balance_be(&MerkleAirdrop::::account_id(), large_balance); - - AirdropInfo::::mutate(airdrop_id, |info| { - if let Some(info) = info { - info.balance = tiny_amount; - } - }); - - #[extrinsic_call] - delete_airdrop(RawOrigin::Signed(caller), airdrop_id); - } - - impl_benchmark_test_suite!(MerkleAirdrop, crate::mock::new_test_ext(), crate::mock::Test); -} diff --git a/pallets/merkle-airdrop/src/lib.rs b/pallets/merkle-airdrop/src/lib.rs deleted file mode 100644 index b3b2651d..00000000 --- a/pallets/merkle-airdrop/src/lib.rs +++ /dev/null @@ -1,584 +0,0 @@ -//! # Merkle Airdrop Pallet -//! -//! A pallet for distributing tokens via Merkle proofs, allowing efficient token airdrops -//! where recipients can claim their tokens by providing cryptographic proofs of eligibility. -//! -//! ## Overview -//! -//! This pallet provides functionality for: -//! - Creating airdrops with a Merkle root representing all valid claims, and optional vesting -//! parameters -//! - Funding airdrops with tokens to be distributed -//! - Allowing users to claim tokens by providing Merkle proofs -//! - Allowing creators to delete airdrops and reclaim any unclaimed tokens -//! -//! The use of Merkle trees allows for gas-efficient verification of eligibility without -//! storing the complete list of recipients on-chain. -//! -//! ## Interface -//! -//! ### Dispatchable Functions -//! -//! * `create_airdrop` - Create a new airdrop with a Merkle root and vesting parameters -//! * `fund_airdrop` - Fund an existing airdrop with tokens -//! * `claim` - Claim tokens from an airdrop by providing a Merkle proof -//! * `delete_airdrop` - Delete an airdrop and reclaim any remaining tokens (creator only) - -#![cfg_attr(not(feature = "std"), no_std)] - -use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; -use frame_system::pallet_prelude::BlockNumberFor; -pub use pallet::*; - -#[cfg(test)] -mod mock; - -#[cfg(test)] -mod tests; - -#[cfg(feature = "runtime-benchmarks")] -mod benchmarking; -pub mod weights; -use scale_info::TypeInfo; -use sp_core::RuntimeDebug; -pub use weights::*; - -use frame_support::traits::{Currency, VestedTransfer}; - -/// NOTE: Vesting traits still use deprecated `Currency` trait. -type CurrencyOf = - <::Vesting as VestedTransfer<::AccountId>>::Currency; - -/// NOTE: Vesting traits still use deprecated `Currency` trait. -type BalanceOf = as Currency<::AccountId>>::Balance; - -/// Type alias for airdrop info for this pallet -type AirdropMetadataFor = - AirdropMetadata, BalanceOf, ::AccountId>; - -/// Type for storing a Merkle root hash -pub type MerkleRoot = [u8; 32]; - -/// Type for Merkle hash values -pub type MerkleHash = [u8; 32]; - -/// Airdrop ID type -pub type AirdropId = u32; - -#[derive( - Encode, - Decode, - PartialEq, - Eq, - Clone, - TypeInfo, - RuntimeDebug, - MaxEncodedLen, - DecodeWithMemTracking, -)] -pub struct AirdropMetadata { - /// Merkle root of the airdrop - pub merkle_root: MerkleHash, - /// Creator of the airdrop - pub creator: AccountId, - /// Current airdrop balance - pub balance: Balance, - /// Vesting period for the airdrop. `None` for immediate release. - pub vesting_period: Option, - /// Vesting start delay. `None` for immediate start - pub vesting_delay: Option, -} - -#[frame_support::pallet] -pub mod pallet { - use crate::{ - AirdropId, AirdropMetadata, AirdropMetadataFor, BalanceOf, CurrencyOf, MerkleHash, - MerkleRoot, - }; - - use super::weights::WeightInfo; - use frame_support::{ - pallet_prelude::*, - traits::{Currency, Get, VestedTransfer, VestingSchedule}, - }; - use frame_system::pallet_prelude::{BlockNumberFor, *}; - use sp_io::hashing::blake2_256; - use sp_runtime::{ - traits::{AccountIdConversion, BlockNumberProvider, Convert, Saturating}, - transaction_validity::{ - InvalidTransaction, TransactionLongevity, TransactionSource, TransactionValidity, - ValidTransaction, - }, - }; - extern crate alloc; - use alloc::vec; - - #[pallet::pallet] - pub struct Pallet(_); - - /// Configuration trait for the Merkle airdrop pallet. - #[pallet::config] - pub trait Config: frame_system::Config { - /// The vesting mechanism. - type Vesting: VestedTransfer> - + VestingSchedule>; - - /// Convert the block number into a balance. - type BlockNumberToBalance: Convert, BalanceOf>; - - /// The maximum number of proof elements allowed in a Merkle proof. - #[pallet::constant] - type MaxProofs: Get; - - /// The pallet id, used for deriving its sovereign account ID. - #[pallet::constant] - type PalletId: Get; - - /// Priority for unsigned claim transactions. - #[pallet::constant] - type UnsignedClaimPriority: Get; - - /// Weight information for the extrinsics in this pallet. - type WeightInfo: WeightInfo; - - /// Block number provider. - type BlockNumberProvider: BlockNumberProvider>; - } - - /// Stores general info about an airdrop - #[pallet::storage] - #[pallet::getter(fn airdrop_info)] - pub type AirdropInfo = StorageMap< - _, - Blake2_128Concat, - AirdropId, - AirdropMetadata, BalanceOf, T::AccountId>, - >; - - /// Storage for claimed status - #[pallet::storage] - #[pallet::getter(fn is_claimed)] - #[allow(clippy::unused_unit)] - pub type Claimed = StorageDoubleMap< - _, - Blake2_128Concat, - AirdropId, - Blake2_128Concat, - T::AccountId, - (), - ValueQuery, - >; - - /// Counter for airdrop IDs - #[pallet::storage] - #[pallet::getter(fn next_airdrop_id)] - pub type NextAirdropId = StorageValue<_, AirdropId, ValueQuery>; - - #[pallet::event] - #[pallet::generate_deposit(pub(super) fn deposit_event)] - pub enum Event { - /// A new airdrop has been created. - /// - /// Parameters: [airdrop_id, merkle_root] - AirdropCreated { - /// The ID of the created airdrop - airdrop_id: AirdropId, - /// Airdrop metadata - airdrop_metadata: AirdropMetadataFor, - }, - /// An airdrop has been funded with tokens. - /// - /// Parameters: [airdrop_id, amount] - AirdropFunded { - /// The ID of the funded airdrop - airdrop_id: AirdropId, - /// The amount of tokens added to the airdrop - amount: BalanceOf, - }, - /// A user has claimed tokens from an airdrop. - /// - /// Parameters: [airdrop_id, account, amount] - Claimed { - /// The ID of the airdrop claimed from - airdrop_id: AirdropId, - /// The account that claimed the tokens - account: T::AccountId, - /// The amount of tokens claimed - amount: BalanceOf, - }, - /// An airdrop has been deleted. - /// - /// Parameters: [airdrop_id] - AirdropDeleted { - /// The ID of the deleted airdrop - airdrop_id: AirdropId, - }, - } - - #[pallet::error] - #[repr(u8)] - pub enum Error { - /// The specified airdrop does not exist. - AirdropNotFound, - /// The airdrop does not have sufficient balance for this operation. - InsufficientAirdropBalance, - /// The user has already claimed from this airdrop. - AlreadyClaimed, - /// The provided Merkle proof is invalid. - InvalidProof, - /// Only the creator of an airdrop can delete it. - NotAirdropCreator, - } - - impl Error { - /// Convert the error to its underlying code - pub fn to_code(&self) -> u8 { - match self { - Error::::AirdropNotFound => 1, - Error::::InsufficientAirdropBalance => 2, - Error::::AlreadyClaimed => 3, - Error::::InvalidProof => 4, - Error::::NotAirdropCreator => 5, - _ => 0, - } - } - } - - impl Pallet { - /// Returns the account ID of the pallet. - /// - /// This account is used to hold the funds for all airdrops. - pub fn account_id() -> T::AccountId { - T::PalletId::get().into_account_truncating() - } - - /// Verifies a Merkle proof against a Merkle root using Blake2 hash. - /// - /// This function checks if an account is eligible to claim a specific amount from an - /// airdrop by verifying a Merkle proof against the stored Merkle root. - /// - /// # Parameters - /// - /// * `account` - The account ID claiming tokens - /// * `amount` - The amount of tokens being claimed - /// * `merkle_root` - The Merkle root to verify against - /// * `merkle_proof` - The proof path from the leaf to the root - /// - /// # Returns - /// - /// `true` if the proof is valid, `false` otherwise - pub fn verify_merkle_proof( - account: &T::AccountId, - amount: BalanceOf, - merkle_root: &MerkleRoot, - merkle_proof: &[MerkleHash], - ) -> bool { - let leaf = Self::calculate_leaf_hash_blake2(account, amount); - - // Verify the proof by walking up the tree - let mut computed_hash = leaf; - for proof_element in merkle_proof.iter() { - computed_hash = if computed_hash < *proof_element { - Self::calculate_parent_hash_blake2(&computed_hash, proof_element) - } else { - Self::calculate_parent_hash_blake2(proof_element, &computed_hash) - }; - } - computed_hash == *merkle_root - } - - /// Calculates the leaf hash for a Merkle tree using Blake2. - /// - /// This function creates a leaf node hash from an account and amount using the - /// Blake2 hash function, which is optimized for zero-knowledge proofs. - /// - /// # Parameters - /// - /// * `account` - The account ID to include in the leaf - /// * `amount` - The token amount to include in the leaf - /// - /// # Returns - /// - /// A 32-byte array containing the Blake2 hash of the account and amount - pub fn calculate_leaf_hash_blake2( - account: &T::AccountId, - amount: BalanceOf, - ) -> MerkleHash { - let bytes = (account, amount).encode(); - blake2_256(&bytes) - } - - /// Calculates the parent hash in a Merkle tree using Blake2. - /// - /// This function combines two child hashes to create their parent hash in the Merkle tree. - /// The children are ordered lexicographically before hashing to ensure consistency. - /// - /// # Parameters - /// - /// * `left` - The first child hash - /// * `right` - The second child hash - /// - /// # Returns - /// - /// A 32-byte array containing the Blake2 hash of the combined children - pub fn calculate_parent_hash_blake2(left: &MerkleHash, right: &MerkleHash) -> MerkleHash { - // Ensure consistent ordering of inputs (important for verification) - let combined = if left < right { - [left.as_slice(), right.as_slice()].concat() - } else { - [right.as_slice(), left.as_slice()].concat() - }; - - blake2_256(&combined) - } - } - - #[pallet::call] - impl Pallet { - /// Create a new airdrop with a Merkle root. - /// - /// The Merkle root is a cryptographic hash that represents all valid claims - /// for this airdrop. Users will later provide Merkle proofs to verify their - /// eligibility to claim tokens. - /// - /// # Parameters - /// - /// * `origin` - The origin of the call (must be signed) - /// * `merkle_root` - The Merkle root hash representing all valid claims - /// * `vesting_period` - Optional vesting period for the airdrop - /// * `vesting_delay` - Optional delay before vesting starts - #[pallet::call_index(0)] - #[pallet::weight(T::WeightInfo::create_airdrop())] - pub fn create_airdrop( - origin: OriginFor, - merkle_root: MerkleRoot, - vesting_period: Option>, - vesting_delay: Option>, - ) -> DispatchResult { - let who = ensure_signed(origin)?; - - let airdrop_id = Self::next_airdrop_id(); - - let airdrop_metadata = AirdropMetadata { - merkle_root, - creator: who.clone(), - balance: Zero::zero(), - vesting_period, - vesting_delay, - }; - - AirdropInfo::::insert(airdrop_id, &airdrop_metadata); - NextAirdropId::::put(airdrop_id.saturating_add(1)); - - Self::deposit_event(Event::AirdropCreated { airdrop_id, airdrop_metadata }); - - Ok(()) - } - - /// Fund an existing airdrop with tokens. - /// - /// This function transfers tokens from the caller to the airdrop's account, - /// making them available for users to claim. - /// - /// # Parameters - /// - /// * `origin` - The origin of the call (must be signed) - /// * `airdrop_id` - The ID of the airdrop to fund - /// * `amount` - The amount of tokens to add to the airdrop - /// - /// # Errors - /// - /// * `AirdropNotFound` - If the specified airdrop does not exist - #[pallet::call_index(1)] - #[pallet::weight(T::WeightInfo::fund_airdrop())] - pub fn fund_airdrop( - origin: OriginFor, - airdrop_id: AirdropId, - amount: BalanceOf, - ) -> DispatchResult { - let who = ensure_signed(origin)?; - - ensure!(AirdropInfo::::contains_key(airdrop_id), Error::::AirdropNotFound); - - CurrencyOf::::transfer( - &who, - &Self::account_id(), - amount, - frame_support::traits::ExistenceRequirement::KeepAlive, - )?; - - AirdropInfo::::mutate(airdrop_id, |maybe_metadata| { - if let Some(metadata) = maybe_metadata { - metadata.balance = metadata.balance.saturating_add(amount); - } - }); - - Self::deposit_event(Event::AirdropFunded { airdrop_id, amount }); - - Ok(()) - } - - /// Claim tokens from an airdrop by providing a Merkle proof. - /// - /// Users can claim their tokens by providing a proof of their eligibility. - /// The proof is verified against the airdrop's Merkle root. - /// Anyone can trigger a claim for any eligible recipient. - /// - /// # Parameters - /// - /// * `origin` - The origin of the call - /// * `airdrop_id` - The ID of the airdrop to claim from - /// * `amount` - The amount of tokens to claim - /// * `merkle_proof` - The Merkle proof verifying eligibility - /// - /// # Errors - /// - /// * `AirdropNotFound` - If the specified airdrop does not exist - /// * `AlreadyClaimed` - If the recipient has already claimed from this airdrop - /// * `InvalidProof` - If the provided Merkle proof is invalid - /// * `InsufficientAirdropBalance` - If the airdrop doesn't have enough tokens - #[pallet::call_index(2)] - #[pallet::weight(T::WeightInfo::claim(merkle_proof.len() as u32))] - pub fn claim( - origin: OriginFor, - airdrop_id: AirdropId, - recipient: T::AccountId, - amount: BalanceOf, - merkle_proof: BoundedVec, - ) -> DispatchResult { - ensure_none(origin)?; - - ensure!( - !Claimed::::contains_key(airdrop_id, &recipient), - Error::::AlreadyClaimed - ); - - let airdrop_metadata = - AirdropInfo::::get(airdrop_id).ok_or(Error::::AirdropNotFound)?; - - ensure!( - Self::verify_merkle_proof( - &recipient, - amount, - &airdrop_metadata.merkle_root, - &merkle_proof - ), - Error::::InvalidProof - ); - - ensure!(airdrop_metadata.balance >= amount, Error::::InsufficientAirdropBalance); - - // Mark as claimed before performing the transfer - Claimed::::insert(airdrop_id, &recipient, ()); - - AirdropInfo::::mutate(airdrop_id, |maybe_metadata| { - if let Some(metadata) = maybe_metadata { - metadata.balance = metadata.balance.saturating_sub(amount); - } - }); - - let per_block = if let Some(vesting_period) = airdrop_metadata.vesting_period { - amount - .checked_div(&T::BlockNumberToBalance::convert(vesting_period)) - .ok_or(Error::::InsufficientAirdropBalance)? - } else { - amount - }; - - let current_block = T::BlockNumberProvider::current_block_number(); - let vesting_start = - current_block.saturating_add(airdrop_metadata.vesting_delay.unwrap_or_default()); - - T::Vesting::vested_transfer( - &Self::account_id(), - &recipient, - amount, - per_block, - vesting_start, - )?; - - Self::deposit_event(Event::Claimed { airdrop_id, account: recipient, amount }); - - Ok(()) - } - - /// Delete an airdrop and reclaim any remaining funds. - /// - /// This function allows the creator of an airdrop to delete it and reclaim - /// any remaining tokens that haven't been claimed. - /// - /// # Parameters - /// - /// * `origin` - The origin of the call (must be the airdrop creator) - /// * `airdrop_id` - The ID of the airdrop to delete - /// - /// # Errors - /// - /// * `AirdropNotFound` - If the specified airdrop does not exist - /// * `NotAirdropCreator` - If the caller is not the creator of the airdrop - #[pallet::call_index(3)] - #[pallet::weight(T::WeightInfo::delete_airdrop())] - pub fn delete_airdrop(origin: OriginFor, airdrop_id: AirdropId) -> DispatchResult { - let who = ensure_signed(origin)?; - - let airdrop_metadata = - AirdropInfo::::take(airdrop_id).ok_or(Error::::AirdropNotFound)?; - - ensure!(airdrop_metadata.creator == who, Error::::NotAirdropCreator); - - CurrencyOf::::transfer( - &Self::account_id(), - &airdrop_metadata.creator, - airdrop_metadata.balance, - frame_support::traits::ExistenceRequirement::KeepAlive, - )?; - - Self::deposit_event(Event::AirdropDeleted { airdrop_id }); - - Ok(()) - } - } - - #[pallet::validate_unsigned] - impl ValidateUnsigned for Pallet { - type Call = Call; - - fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { - if let Call::claim { airdrop_id, recipient, amount, merkle_proof } = call { - // 1. Check if airdrop exists - let airdrop_metadata = AirdropInfo::::get(airdrop_id).ok_or_else(|| { - let error = Error::::AirdropNotFound; - InvalidTransaction::Custom(error.to_code()) - })?; - - // 2. Check if already claimed - if Claimed::::contains_key(airdrop_id, recipient) { - let error = Error::::AlreadyClaimed; - return InvalidTransaction::Custom(error.to_code()).into(); - } - - // 3. Verify Merkle Proof - if !Self::verify_merkle_proof( - recipient, - *amount, - &airdrop_metadata.merkle_root, - merkle_proof, - ) { - let error = Error::::InvalidProof; - return InvalidTransaction::Custom(error.to_code()).into(); - } - - Ok(ValidTransaction { - priority: T::UnsignedClaimPriority::get(), - requires: vec![], - provides: vec![(airdrop_id, recipient, amount).encode()], - longevity: TransactionLongevity::MAX, - propagate: true, - }) - } else { - log::error!(target: "merkle-airdrop", "ValidateUnsigned: Received non-claim transaction or unexpected call structure"); - InvalidTransaction::Call.into() - } - } - } -} diff --git a/pallets/merkle-airdrop/src/mock.rs b/pallets/merkle-airdrop/src/mock.rs deleted file mode 100644 index 0a5c865c..00000000 --- a/pallets/merkle-airdrop/src/mock.rs +++ /dev/null @@ -1,129 +0,0 @@ -use crate as pallet_merkle_airdrop; -use frame_support::{ - parameter_types, - traits::{ConstU32, Everything, WithdrawReasons}, - PalletId, -}; -use frame_system::{self as system}; -use sp_core::H256; -use sp_runtime::{ - traits::{BlakeTwo256, ConvertInto, IdentityLookup}, - BuildStorage, -}; - -type Block = frame_system::mocking::MockBlock; - -// Configure a mock runtime to test the pallet. -frame_support::construct_runtime!( - pub enum Test { - System: frame_system, - Vesting: pallet_vesting, - Balances: pallet_balances, - MerkleAirdrop: pallet_merkle_airdrop, - } -); - -parameter_types! { - pub const BlockHashCount: u64 = 250; - pub const SS58Prefix: u8 = 189; -} - -impl system::Config for Test { - type BaseCallFilter = Everything; - type BlockWeights = (); - type BlockLength = (); - type DbWeight = (); - type RuntimeOrigin = RuntimeOrigin; - type RuntimeCall = RuntimeCall; - type Nonce = u64; - type Hash = H256; - type Hashing = BlakeTwo256; - type AccountId = u64; - type Lookup = IdentityLookup; - type Block = Block; - type BlockHashCount = BlockHashCount; - type Version = (); - type PalletInfo = PalletInfo; - type AccountData = pallet_balances::AccountData; - type OnNewAccount = (); - type OnKilledAccount = (); - type SystemWeightInfo = (); - type SS58Prefix = SS58Prefix; - type OnSetCode = (); - type MaxConsumers = ConstU32<16>; - type RuntimeTask = (); - type ExtensionsWeightInfo = (); - type SingleBlockMigrations = (); - type MultiBlockMigrator = (); - type PreInherents = (); - type PostInherents = (); - type PostTransactions = (); - type RuntimeEvent = RuntimeEvent; -} - -parameter_types! { - pub const ExistentialDeposit: u64 = 1; - pub const MaxLocks: u32 = 50; -} - -impl pallet_balances::Config for Test { - type Balance = u64; - type DustRemoval = (); - type ExistentialDeposit = ExistentialDeposit; - type AccountStore = System; - type WeightInfo = (); - type MaxLocks = MaxLocks; - type MaxReserves = (); - type ReserveIdentifier = [u8; 8]; - type RuntimeHoldReason = (); - type FreezeIdentifier = (); - type MaxFreezes = (); - type RuntimeFreezeReason = (); - type DoneSlashHandler = (); -} - -parameter_types! { - pub const MinVestedTransfer: u64 = 1; - pub UnvestedFundsAllowedWithdrawReasons: WithdrawReasons = - WithdrawReasons::except(WithdrawReasons::TRANSFER | WithdrawReasons::RESERVE); -} - -impl pallet_vesting::Config for Test { - type RuntimeEvent = RuntimeEvent; - type Currency = Balances; - type WeightInfo = (); - type BlockNumberProvider = System; - type MinVestedTransfer = MinVestedTransfer; - type BlockNumberToBalance = ConvertInto; - type UnvestedFundsAllowedWithdrawReasons = UnvestedFundsAllowedWithdrawReasons; - - const MAX_VESTING_SCHEDULES: u32 = 3; -} - -parameter_types! { - pub const MaxProofs: u32 = 100; - pub const MerkleAirdropPalletId: PalletId = PalletId(*b"airdrop!"); - pub const UnsignedClaimPriority: u64 = 100; -} - -impl pallet_merkle_airdrop::Config for Test { - type Vesting = Vesting; - type MaxProofs = MaxProofs; - type PalletId = MerkleAirdropPalletId; - type UnsignedClaimPriority = UnsignedClaimPriority; - type WeightInfo = (); - type BlockNumberProvider = System; - type BlockNumberToBalance = ConvertInto; -} - -// 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![(1, 10_000_000), (MerkleAirdrop::account_id(), 1)], - } - .assimilate_storage(&mut t) - .unwrap(); - - t.into() -} diff --git a/pallets/merkle-airdrop/src/tests.rs b/pallets/merkle-airdrop/src/tests.rs deleted file mode 100644 index a142b6c5..00000000 --- a/pallets/merkle-airdrop/src/tests.rs +++ /dev/null @@ -1,591 +0,0 @@ -#![allow(clippy::unit_cmp)] - -use crate::{mock::*, Error, Event}; -use codec::Encode; -use frame_support::{ - assert_noop, assert_ok, - traits::{InspectLockableCurrency, LockIdentifier}, - BoundedVec, -}; -use sp_core::blake2_256; -use sp_runtime::TokenError; - -fn bounded_proof(proof: Vec<[u8; 32]>) -> BoundedVec<[u8; 32], MaxProofs> { - proof.try_into().expect("Proof exceeds maximum size") -} - -// Helper function to calculate a leaf hash for testing -fn calculate_leaf_hash(account: &u64, amount: u64) -> [u8; 32] { - let account_bytes = account.encode(); - let amount_bytes = amount.encode(); - let leaf_data = [&account_bytes[..], &amount_bytes[..]].concat(); - - blake2_256(&leaf_data) -} - -// Helper function to calculate a parent hash for testing -fn calculate_parent_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { - let combined = if left < right { - [&left[..], &right[..]].concat() - } else { - [&right[..], &left[..]].concat() - }; - - blake2_256(&combined) -} - -const VESTING_ID: LockIdentifier = *b"vesting "; - -#[test] -fn create_airdrop_works() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let merkle_root = [0u8; 32]; - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(1), - merkle_root, - Some(100), - Some(10) - )); - - let airdrop_metadata = crate::AirdropMetadata { - merkle_root, - creator: 1, - balance: 0, - vesting_period: Some(100), - vesting_delay: Some(10), - }; - - System::assert_last_event( - Event::AirdropCreated { airdrop_id: 0, airdrop_metadata: airdrop_metadata.clone() } - .into(), - ); - - assert_eq!(MerkleAirdrop::airdrop_info(0), Some(airdrop_metadata)); - }); -} - -#[test] -fn fund_airdrop_works() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let merkle_root = [0u8; 32]; - let amount = 100; - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(1), - merkle_root, - Some(10), - Some(10) - )); - - assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, 0); - - // fund airdrop with insufficient balance should fail - assert_noop!( - MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(123456), 0, amount * 10000), - TokenError::FundsUnavailable, - ); - - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, amount)); - - System::assert_last_event(Event::AirdropFunded { airdrop_id: 0, amount }.into()); - - // Check that the airdrop balance was updated - assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, amount); - - // Check that the balance was transferred - assert_eq!(Balances::free_balance(1), 9999900); // 10000000 - 100 - assert_eq!(Balances::free_balance(MerkleAirdrop::account_id()), 101); - - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, amount)); - - assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, amount * 2); - assert_eq!(Balances::free_balance(1), 9999800); // 9999900 - 100 - assert_eq!(Balances::free_balance(MerkleAirdrop::account_id()), 201); // locked for vesting - }); -} - -#[test] -fn claim_works() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - let account1: u64 = 2; // Account that will claim - let amount1: u64 = 500; - let account2: u64 = 3; - let amount2: u64 = 300; - - let leaf1 = calculate_leaf_hash(&account1, amount1); - let leaf2 = calculate_leaf_hash(&account2, amount2); - let merkle_root = calculate_parent_hash(&leaf1, &leaf2); - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(1), - merkle_root, - Some(100), - Some(2) - )); - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 1000)); - - // Create proof for account1d - let merkle_proof = bounded_proof(vec![leaf2]); - - assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 2, 500, merkle_proof.clone())); - - System::assert_last_event(Event::Claimed { airdrop_id: 0, account: 2, amount: 500 }.into()); - - assert_eq!(MerkleAirdrop::is_claimed(0, 2), ()); - assert_eq!(Balances::balance_locked(VESTING_ID, &2), 500); // Unlocked - - assert_eq!(Balances::free_balance(2), 500); - assert_eq!(Balances::free_balance(MerkleAirdrop::account_id()), 501); // 1 (initial) + 1000 - // (funded) - 500 (claimed) - }); -} - -#[test] -fn create_airdrop_requires_signed_origin() { - new_test_ext().execute_with(|| { - let merkle_root = [0u8; 32]; - - assert_noop!( - MerkleAirdrop::create_airdrop(RuntimeOrigin::none(), merkle_root, None, None), - frame_support::error::BadOrigin - ); - }); -} - -#[test] -fn fund_airdrop_fails_for_nonexistent_airdrop() { - new_test_ext().execute_with(|| { - assert_noop!( - MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 999, 1000), - Error::::AirdropNotFound - ); - }); -} - -#[test] -fn claim_fails_for_nonexistent_airdrop() { - new_test_ext().execute_with(|| { - let merkle_proof = bounded_proof(vec![[0u8; 32]]); - - assert_noop!( - MerkleAirdrop::claim(RuntimeOrigin::none(), 999, 1, 500, merkle_proof), - Error::::AirdropNotFound - ); - }); -} - -#[test] -fn claim_already_claimed() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let account1: u64 = 2; // Account that will claim - let amount1: u64 = 500; - let account2: u64 = 3; - let amount2: u64 = 300; - - let leaf1 = calculate_leaf_hash(&account1, amount1); - let leaf2 = calculate_leaf_hash(&account2, amount2); - let merkle_root = calculate_parent_hash(&leaf1, &leaf2); - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(1), - merkle_root, - Some(100), - Some(10) - )); - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 1000)); - - let merkle_proof = bounded_proof(vec![leaf2]); - - assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 2, 500, merkle_proof.clone())); - - // Try to claim again - assert_noop!( - MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 2, 500, merkle_proof.clone()), - Error::::AlreadyClaimed - ); - }); -} - -#[test] -fn verify_merkle_proof_works() { - new_test_ext().execute_with(|| { - // Create test accounts and amounts - let account1: u64 = 1; - let amount1: u64 = 500; - let account2: u64 = 2; - let amount2: u64 = 300; - - // Calculate leaf hashes - let leaf1 = calculate_leaf_hash(&account1, amount1); - let leaf2 = calculate_leaf_hash(&account2, amount2); - - // Calculate the Merkle root (hash of the two leaves) - let merkle_root = calculate_parent_hash(&leaf1, &leaf2); - - // Create proofs - let proof_for_account1 = vec![leaf2]; - let proof_for_account2 = vec![leaf1]; - - // Test the verify_merkle_proof function directly - assert!( - MerkleAirdrop::verify_merkle_proof( - &account1, - amount1, - &merkle_root, - &proof_for_account1 - ), - "Proof for account1 should be valid" - ); - - assert!( - MerkleAirdrop::verify_merkle_proof( - &account2, - amount2, - &merkle_root, - &proof_for_account2 - ), - "Proof for account2 should be valid" - ); - - assert!( - !MerkleAirdrop::verify_merkle_proof( - &account1, - 400, // Wrong amount - &merkle_root, - &proof_for_account1 - ), - "Proof with wrong amount should be invalid" - ); - - let wrong_proof = vec![[1u8; 32]]; - assert!( - !MerkleAirdrop::verify_merkle_proof(&account1, amount1, &merkle_root, &wrong_proof), - "Wrong proof should be invalid" - ); - - assert!( - !MerkleAirdrop::verify_merkle_proof( - &3, // Wrong account - amount1, - &merkle_root, - &proof_for_account1 - ), - "Proof with wrong account should be invalid" - ); - }); -} - -#[test] -fn claim_invalid_proof_fails() { - new_test_ext().execute_with(|| { - let account1: u64 = 2; - let amount1: u64 = 500; - let account2: u64 = 3; - let amount2: u64 = 300; - - let leaf1 = calculate_leaf_hash(&account1, amount1); - let leaf2 = calculate_leaf_hash(&account2, amount2); - let merkle_root = calculate_parent_hash(&leaf1, &leaf2); - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(1), - merkle_root, - Some(100), - Some(10) - )); - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 1000)); - - let invalid_proof = bounded_proof(vec![[1u8; 32]]); // Different from the actual leaf2 - - assert_noop!( - MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 2, 500, invalid_proof), - Error::::InvalidProof - ); - }); -} - -#[test] -fn claim_insufficient_airdrop_balance_fails() { - new_test_ext().execute_with(|| { - // Create a valid merkle tree - let account1: u64 = 2; - let amount1: u64 = 500; - let account2: u64 = 3; - let amount2: u64 = 300; - - let leaf1 = calculate_leaf_hash(&account1, amount1); - let leaf2 = calculate_leaf_hash(&account2, amount2); - let merkle_root = calculate_parent_hash(&leaf1, &leaf2); - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(1), - merkle_root, - Some(1000), - Some(100) - )); - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 400)); // Fund less than claim amount - - let merkle_proof = bounded_proof(vec![leaf2]); - - // Attempt to claim more than available - assert_noop!( - MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 2, 500, merkle_proof), - Error::::InsufficientAirdropBalance - ); - }); -} - -#[test] -fn claim_nonexistent_airdrop_fails() { - new_test_ext().execute_with(|| { - // Attempt to claim from a nonexistent airdrop - assert_noop!( - MerkleAirdrop::claim( - RuntimeOrigin::none(), - 999, - 2, - 500, - bounded_proof(vec![[0u8; 32]]) - ), - Error::::AirdropNotFound - ); - }); -} - -#[test] -fn claim_updates_balances_correctly() { - new_test_ext().execute_with(|| { - // Create a valid merkle tree - let account1: u64 = 2; - let amount1: u64 = 500; - let account2: u64 = 3; - let amount2: u64 = 300; - - let leaf1 = calculate_leaf_hash(&account1, amount1); - let leaf2 = calculate_leaf_hash(&account2, amount2); - let merkle_root = calculate_parent_hash(&leaf1, &leaf2); - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(1), - merkle_root, - Some(100), - Some(10) - )); - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 1000)); - - let initial_account_balance = Balances::free_balance(2); - let initial_pallet_balance = Balances::free_balance(MerkleAirdrop::account_id()); - - assert_ok!(MerkleAirdrop::claim( - RuntimeOrigin::none(), - 0, - 2, - 500, - bounded_proof(vec![leaf2]) - )); - - assert_eq!(Balances::free_balance(2), initial_account_balance + 500); - assert_eq!( - Balances::free_balance(MerkleAirdrop::account_id()), - initial_pallet_balance - 500 - ); - - assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, 500); - assert_eq!(MerkleAirdrop::is_claimed(0, 2), ()); - }); -} - -#[test] -fn multiple_users_can_claim() { - new_test_ext().execute_with(|| { - let account1: u64 = 2; - let amount1: u64 = 5000; - let account2: u64 = 3; - let amount2: u64 = 3000; - let account3: u64 = 4; - let amount3: u64 = 2000; - - let leaf1 = calculate_leaf_hash(&account1, amount1); - let leaf2 = calculate_leaf_hash(&account2, amount2); - let leaf3 = calculate_leaf_hash(&account3, amount3); - let parent1 = calculate_parent_hash(&leaf1, &leaf2); - let merkle_root = calculate_parent_hash(&parent1, &leaf3); - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(1), - merkle_root, - Some(1000), - Some(10) - )); - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 10001)); - - // User 1 claims - let proof1 = bounded_proof(vec![leaf2, leaf3]); - assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 2, 5000, proof1)); - assert_eq!(Balances::free_balance(2), 5000); // free balance but it's locked for vesting - assert_eq!(Balances::balance_locked(VESTING_ID, &2), 5000); - - // User 2 claims - let proof2 = bounded_proof(vec![leaf1, leaf3]); - assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 3, 3000, proof2)); - assert_eq!(Balances::free_balance(3), 3000); - assert_eq!(Balances::balance_locked(VESTING_ID, &3), 3000); - - // User 3 claims - let proof3 = bounded_proof(vec![parent1]); - assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 4, 2000, proof3)); - assert_eq!(Balances::free_balance(4), 2000); - assert_eq!(Balances::balance_locked(VESTING_ID, &4), 2000); - - assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, 1); - - assert_eq!(MerkleAirdrop::is_claimed(0, 2), ()); - assert_eq!(MerkleAirdrop::is_claimed(0, 3), ()); - assert_eq!(MerkleAirdrop::is_claimed(0, 4), ()); - }); -} - -#[test] -fn delete_airdrop_works() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let merkle_root = [0u8; 32]; - let creator = 1; - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(creator), - merkle_root, - Some(100), - Some(10) - )); - - let airdrop_info = MerkleAirdrop::airdrop_info(0).unwrap(); - - assert_eq!(airdrop_info.creator, creator); - - // Delete the airdrop (balance is zero) - assert_ok!(MerkleAirdrop::delete_airdrop(RuntimeOrigin::signed(creator), 0)); - - System::assert_last_event(Event::AirdropDeleted { airdrop_id: 0 }.into()); - - // Check that the airdrop no longer exists - assert!(MerkleAirdrop::airdrop_info(0).is_none()); - }); -} - -#[test] -fn delete_airdrop_with_balance_refunds_creator() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let merkle_root = [0u8; 32]; - let creator = 1; - let initial_creator_balance = Balances::free_balance(creator); - let fund_amount = 100; - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(creator), - merkle_root, - Some(100), - Some(10) - )); - - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(creator), 0, fund_amount)); - - // Creator's balance should be reduced by fund_amount - assert_eq!(Balances::free_balance(creator), initial_creator_balance - fund_amount); - - assert_ok!(MerkleAirdrop::delete_airdrop(RuntimeOrigin::signed(creator), 0)); - - // Check that the funds were returned to the creator - assert_eq!(Balances::free_balance(creator), initial_creator_balance); - - System::assert_last_event(Event::AirdropDeleted { airdrop_id: 0 }.into()); - }); -} - -#[test] -fn delete_airdrop_non_creator_fails() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let merkle_root = [0u8; 32]; - let creator = 1; - let non_creator = 2; - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(creator), - merkle_root, - Some(100), - Some(10) - )); - - assert_noop!( - MerkleAirdrop::delete_airdrop(RuntimeOrigin::signed(non_creator), 0), - Error::::NotAirdropCreator - ); - }); -} - -#[test] -fn delete_airdrop_nonexistent_fails() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - assert_noop!( - MerkleAirdrop::delete_airdrop(RuntimeOrigin::signed(1), 999), - Error::::AirdropNotFound - ); - }); -} - -#[test] -fn delete_airdrop_after_claims_works() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator: u64 = 1; - let initial_creator_balance = Balances::free_balance(creator); - let account1: u64 = 2; - let amount1: u64 = 500; - let account2: u64 = 3; - let amount2: u64 = 300; - let total_fund = 1000; - - let leaf1 = calculate_leaf_hash(&account1, amount1); - let leaf2 = calculate_leaf_hash(&account2, amount2); - let merkle_root = calculate_parent_hash(&leaf1, &leaf2); - - assert_ok!(MerkleAirdrop::create_airdrop( - RuntimeOrigin::signed(creator), - merkle_root, - Some(100), - Some(10) - )); - assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(creator), 0, total_fund)); - - // Let only one account claim (partial claiming) - let proof1 = bounded_proof(vec![leaf2]); - assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account1, amount1, proof1)); - - // Check that some balance remains - assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, total_fund - amount1); - - // Now the creator deletes the airdrop with remaining balance - assert_ok!(MerkleAirdrop::delete_airdrop(RuntimeOrigin::signed(creator), 0)); - - // Check creator was refunded the unclaimed amount - assert_eq!( - Balances::free_balance(creator), - initial_creator_balance - total_fund + (total_fund - amount1) - ); - }); -} diff --git a/pallets/merkle-airdrop/src/weights.rs b/pallets/merkle-airdrop/src/weights.rs deleted file mode 100644 index c0213e38..00000000 --- a/pallets/merkle-airdrop/src/weights.rs +++ /dev/null @@ -1,193 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - - -//! Autogenerated weights for `pallet_merkle_airdrop` -//! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 47.2.0 -//! DATE: 2025-06-24, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` -//! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `MacBook-Pro-4.local`, CPU: `` -//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` - -// Executed Command: -// frame-omni-bencher -// v1 -// benchmark -// pallet -// --runtime -// ./target/release/wbuild/quantus-runtime/quantus_runtime.wasm -// --pallet -// pallet-merkle-airdrop -// --extrinsic -// * -// --template -// ./.maintain/frame-weight-template.hbs -// --output -// ./pallets/merkle-airdrop/src/weights.rs - -#![cfg_attr(rustfmt, rustfmt_skip)] -#![allow(unused_parens)] -#![allow(unused_imports)] -#![allow(missing_docs)] -#![allow(dead_code)] - -use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; -use core::marker::PhantomData; - -/// Weight functions needed for `pallet_merkle_airdrop`. -pub trait WeightInfo { - fn create_airdrop() -> Weight; - fn fund_airdrop() -> Weight; - fn claim(p: u32, ) -> Weight; - fn delete_airdrop() -> Weight; -} - -/// Weights for `pallet_merkle_airdrop` using the Substrate node and recommended hardware. -pub struct SubstrateWeight(PhantomData); -impl WeightInfo for SubstrateWeight { - /// Storage: `MerkleAirdrop::NextAirdropId` (r:1 w:1) - /// Proof: `MerkleAirdrop::NextAirdropId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - /// Storage: `MerkleAirdrop::AirdropInfo` (r:0 w:1) - /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - fn create_airdrop() -> Weight { - // Proof Size summary in bytes: - // Measured: `6` - // Estimated: `1489` - // Minimum execution time: 7_000_000 picoseconds. - Weight::from_parts(8_000_000, 1489) - .saturating_add(T::DbWeight::get().reads(1_u64)) - .saturating_add(T::DbWeight::get().writes(2_u64)) - } - /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) - /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - fn fund_airdrop() -> Weight { - // Proof Size summary in bytes: - // Measured: `262` - // Estimated: `3593` - // Minimum execution time: 40_000_000 picoseconds. - Weight::from_parts(42_000_000, 3593) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().writes(2_u64)) - } - /// Storage: `MerkleAirdrop::Claimed` (r:1 w:1) - /// Proof: `MerkleAirdrop::Claimed` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) - /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) - /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - /// Storage: `Vesting::Vesting` (r:1 w:1) - /// Proof: `Vesting::Vesting` (`max_values`: None, `max_size`: Some(1057), added: 3532, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:2 w:2) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Balances::Locks` (r:1 w:1) - /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) - /// Storage: `Balances::Freezes` (r:1 w:0) - /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) - /// The range of component `p` is `[0, 100]`. - fn claim(p: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `441` - // Estimated: `6196` - // Minimum execution time: 73_000_000 picoseconds. - Weight::from_parts(74_879_630, 6196) - // Standard Error: 1_851 - .saturating_add(Weight::from_parts(368_666, 0).saturating_mul(p.into())) - .saturating_add(T::DbWeight::get().reads(7_u64)) - .saturating_add(T::DbWeight::get().writes(6_u64)) - } - /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) - /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - fn delete_airdrop() -> Weight { - // Proof Size summary in bytes: - // Measured: `262` - // Estimated: `3593` - // Minimum execution time: 39_000_000 picoseconds. - Weight::from_parts(39_000_000, 3593) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().writes(2_u64)) - } -} - -// For backwards compatibility and tests. -impl WeightInfo for () { - /// Storage: `MerkleAirdrop::NextAirdropId` (r:1 w:1) - /// Proof: `MerkleAirdrop::NextAirdropId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - /// Storage: `MerkleAirdrop::AirdropInfo` (r:0 w:1) - /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - fn create_airdrop() -> Weight { - // Proof Size summary in bytes: - // Measured: `6` - // Estimated: `1489` - // Minimum execution time: 7_000_000 picoseconds. - Weight::from_parts(8_000_000, 1489) - .saturating_add(RocksDbWeight::get().reads(1_u64)) - .saturating_add(RocksDbWeight::get().writes(2_u64)) - } - /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) - /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - fn fund_airdrop() -> Weight { - // Proof Size summary in bytes: - // Measured: `262` - // Estimated: `3593` - // Minimum execution time: 40_000_000 picoseconds. - Weight::from_parts(42_000_000, 3593) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().writes(2_u64)) - } - /// Storage: `MerkleAirdrop::Claimed` (r:1 w:1) - /// Proof: `MerkleAirdrop::Claimed` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) - /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) - /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - /// Storage: `Vesting::Vesting` (r:1 w:1) - /// Proof: `Vesting::Vesting` (`max_values`: None, `max_size`: Some(1057), added: 3532, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:2 w:2) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Balances::Locks` (r:1 w:1) - /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) - /// Storage: `Balances::Freezes` (r:1 w:0) - /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) - /// The range of component `p` is `[0, 100]`. - fn claim(p: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `441` - // Estimated: `6196` - // Minimum execution time: 73_000_000 picoseconds. - Weight::from_parts(74_879_630, 6196) - // Standard Error: 1_851 - .saturating_add(Weight::from_parts(368_666, 0).saturating_mul(p.into())) - .saturating_add(RocksDbWeight::get().reads(7_u64)) - .saturating_add(RocksDbWeight::get().writes(6_u64)) - } - /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) - /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - fn delete_airdrop() -> Weight { - // Proof Size summary in bytes: - // Measured: `262` - // Estimated: `3593` - // Minimum execution time: 39_000_000 picoseconds. - Weight::from_parts(39_000_000, 3593) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().writes(2_u64)) - } -} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 51c7b67f..4de5e701 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -31,7 +31,6 @@ pallet-assets.workspace = true pallet-assets-holder = { workspace = true, default-features = false } pallet-balances.workspace = true pallet-conviction-voting.workspace = true -pallet-merkle-airdrop.workspace = true pallet-mining-rewards.workspace = true pallet-preimage.workspace = true pallet-qpow.workspace = true @@ -96,7 +95,6 @@ std = [ "pallet-assets/std", "pallet-balances/std", "pallet-conviction-voting/std", - "pallet-merkle-airdrop/std", "pallet-mining-rewards/std", "pallet-preimage/std", "pallet-qpow/std", @@ -145,7 +143,6 @@ runtime-benchmarks = [ "pallet-assets/runtime-benchmarks", "pallet-balances/runtime-benchmarks", "pallet-conviction-voting/runtime-benchmarks", - "pallet-merkle-airdrop/runtime-benchmarks", "pallet-mining-rewards/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", "pallet-qpow/runtime-benchmarks", diff --git a/runtime/src/benchmarks.rs b/runtime/src/benchmarks.rs index c670981c..6efb6e0c 100644 --- a/runtime/src/benchmarks.rs +++ b/runtime/src/benchmarks.rs @@ -30,7 +30,6 @@ frame_benchmarking::define_benchmarks!( [pallet_timestamp, Timestamp] [pallet_sudo, Sudo] [pallet_reversible_transfers, ReversibleTransfers] - [pallet_merkle_airdrop, MerkleAirdrop] [pallet_mining_rewards, MiningRewards] [pallet_scheduler, Scheduler] [pallet_qpow, QPoW] diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index e76063a3..2677aef2 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -85,8 +85,6 @@ parameter_types! { // To upload, 10Mbs link takes 4.1s and 100Mbs takes 500ms pub RuntimeBlockLength: BlockLength = BlockLength::max_with_normal_ratio(5 * 1024 * 1024, NORMAL_DISPATCH_RATIO); pub const SS58Prefix: u8 = 189; - pub const MerkleAirdropPalletId: PalletId = PalletId(*b"airdrop!"); - pub const UnsignedClaimPriority: u32 = 100; } /// The default types are being injected by [`derive_impl`](`frame_support::derive_impl`) from @@ -505,20 +503,6 @@ impl pallet_reversible_transfers::Config for Runtime { type TreasuryAccountId = TreasuryAccountId; } -parameter_types! { - pub const MaxProofs: u32 = 4096; -} - -impl pallet_merkle_airdrop::Config for Runtime { - type Vesting = Vesting; - type MaxProofs = MaxProofs; - type PalletId = MerkleAirdropPalletId; - type WeightInfo = pallet_merkle_airdrop::weights::SubstrateWeight; - type UnsignedClaimPriority = UnsignedClaimPriority; - type BlockNumberProvider = System; - type BlockNumberToBalance = ConvertInto; -} - parameter_types! { pub const TreasuryPalletId: PalletId = PalletId(*b"py/trsry"); pub const ProposalBond: Permill = Permill::from_percent(5); diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 447ac474..1eaf36f0 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -22,7 +22,6 @@ use sp_version::RuntimeVersion; pub use frame_system::Call as SystemCall; pub use pallet_balances::Call as BalancesCall; -pub use pallet_merkle_airdrop; pub use pallet_reversible_transfers as ReversibleTransfersCall; pub use pallet_timestamp::Call as TimestampCall; @@ -243,9 +242,6 @@ mod runtime { #[runtime::pallet_index(16)] pub type TechReferenda = pallet_referenda::Pallet; - #[runtime::pallet_index(17)] - pub type MerkleAirdrop = pallet_merkle_airdrop; - #[runtime::pallet_index(18)] pub type TreasuryPallet = pallet_treasury; From 306bb4fa42c15b0054eba35c0cdf9b12299bd389 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Mon, 12 Jan 2026 11:49:24 +0800 Subject: [PATCH 02/27] feat: Vesting pallet - removed --- Cargo.lock | 16 - Cargo.toml | 1 - runtime/Cargo.toml | 4 - runtime/src/configs/mod.rs | 25 +- runtime/src/lib.rs | 3 - runtime/tests/governance/mod.rs | 1 - runtime/tests/governance/vesting.rs | 619 ---------------------------- 7 files changed, 3 insertions(+), 666 deletions(-) delete mode 100644 runtime/tests/governance/vesting.rs diff --git a/Cargo.lock b/Cargo.lock index ef334b1f..a5147e1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7551,21 +7551,6 @@ dependencies = [ "sp-runtime", ] -[[package]] -name = "pallet-vesting" -version = "41.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "305b437f4832bb563b660afa6549c0f0d446b668b4f098edc48d04e803badb9f" -dependencies = [ - "frame-benchmarking", - "frame-support", - "frame-system", - "log", - "parity-scale-codec", - "scale-info", - "sp-runtime", -] - [[package]] name = "pallet-wormhole" version = "0.1.0" @@ -9186,7 +9171,6 @@ dependencies = [ "pallet-transaction-payment-rpc-runtime-api", "pallet-treasury", "pallet-utility", - "pallet-vesting", "parity-scale-codec", "primitive-types 0.13.1", "qp-dilithium-crypto", diff --git a/Cargo.toml b/Cargo.toml index 32a7f406..bd8bcfd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,7 +184,6 @@ pallet-transaction-payment-rpc = { version = "44.0.0", default-features = false pallet-transaction-payment-rpc-runtime-api = { version = "41.0.0", default-features = false } pallet-treasury = { version = "40.0.0", default-features = false } pallet-utility = { version = "41.0.0", default-features = false } -pallet-vesting = { version = "41.0.0", default-features = false } prometheus-endpoint = { version = "0.17.2", default-features = false, package = "substrate-prometheus-endpoint" } sc-basic-authorship = { version = "0.50.0", default-features = false } sc-block-builder = { version = "0.45.0", default-features = true } diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 4de5e701..6f039fe1 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -45,7 +45,6 @@ pallet-transaction-payment.workspace = true pallet-transaction-payment-rpc-runtime-api.workspace = true pallet-treasury.workspace = true pallet-utility.workspace = true -pallet-vesting.workspace = true primitive-types.workspace = true qp-dilithium-crypto.workspace = true qp-header = { workspace = true, features = ["serde"] } @@ -109,7 +108,6 @@ std = [ "pallet-transaction-payment/std", "pallet-treasury/std", "pallet-utility/std", - "pallet-vesting/std", "primitive-types/std", "qp-dilithium-crypto/full_crypto", "qp-dilithium-crypto/std", @@ -155,7 +153,6 @@ runtime-benchmarks = [ "pallet-timestamp/runtime-benchmarks", "pallet-transaction-payment/runtime-benchmarks", "pallet-treasury/runtime-benchmarks", - "pallet-vesting/runtime-benchmarks", "sp-runtime/runtime-benchmarks", ] @@ -174,7 +171,6 @@ try-runtime = [ "pallet-timestamp/try-runtime", "pallet-transaction-payment/try-runtime", "pallet-treasury/try-runtime", - "pallet-vesting/try-runtime", "sp-runtime/try-runtime", ] diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 2677aef2..28c996b4 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -39,7 +39,7 @@ use frame_support::{ derive_impl, parameter_types, traits::{ AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU8, EitherOf, Get, NeverEnsureOrigin, - VariantCountOf, WithdrawReasons, + VariantCountOf, }, weights::{ constants::{RocksDbWeight, WEIGHT_REF_TIME_PER_SECOND}, @@ -56,7 +56,7 @@ use pallet_transaction_payment::{ConstFeeMultiplier, FungibleAdapter, Multiplier use qp_poseidon::PoseidonHasher; use qp_scheduler::BlockNumberOrTimestamp; use sp_runtime::{ - traits::{AccountIdConversion, ConvertInto, One}, + traits::{AccountIdConversion, One}, FixedU128, Perbill, Permill, }; use sp_version::RuntimeVersion; @@ -65,7 +65,7 @@ use sp_version::RuntimeVersion; use super::{ AccountId, Balance, Balances, Block, BlockNumber, Hash, Nonce, OriginCaller, PalletInfo, Preimage, Referenda, Runtime, RuntimeCall, RuntimeEvent, RuntimeFreezeReason, - RuntimeHoldReason, RuntimeOrigin, RuntimeTask, Scheduler, System, Timestamp, Vesting, DAYS, + RuntimeHoldReason, RuntimeOrigin, RuntimeTask, Scheduler, System, Timestamp, DAYS, EXISTENTIAL_DEPOSIT, MICRO_UNIT, TARGET_BLOCK_TIME_MS, UNIT, VERSION, }; use sp_core::U512; @@ -423,25 +423,6 @@ impl pallet_sudo::Config for Runtime { type WeightInfo = pallet_sudo::weights::SubstrateWeight; } -parameter_types! { - pub const MinVestedTransfer: Balance = UNIT; - /// Unvested funds can be transferred and reserved for any other means (reserves overlap) - pub UnvestedFundsAllowedWithdrawReasons: WithdrawReasons = - WithdrawReasons::except(WithdrawReasons::TRANSFER | WithdrawReasons::RESERVE); -} - -impl pallet_vesting::Config for Runtime { - type Currency = Balances; - type RuntimeEvent = RuntimeEvent; - type WeightInfo = pallet_vesting::weights::SubstrateWeight; - type MinVestedTransfer = MinVestedTransfer; - type BlockNumberToBalance = ConvertInto; - type UnvestedFundsAllowedWithdrawReasons = UnvestedFundsAllowedWithdrawReasons; - type BlockNumberProvider = System; - - const MAX_VESTING_SCHEDULES: u32 = 28; -} - impl pallet_utility::Config for Runtime { type RuntimeCall = RuntimeCall; type RuntimeEvent = RuntimeEvent; diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 1eaf36f0..01b17135 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -215,9 +215,6 @@ mod runtime { #[runtime::pallet_index(7)] pub type MiningRewards = pallet_mining_rewards; - #[runtime::pallet_index(8)] - pub type Vesting = pallet_vesting; - #[runtime::pallet_index(9)] pub type Preimage = pallet_preimage; diff --git a/runtime/tests/governance/mod.rs b/runtime/tests/governance/mod.rs index 13fa1876..1261a867 100644 --- a/runtime/tests/governance/mod.rs +++ b/runtime/tests/governance/mod.rs @@ -2,4 +2,3 @@ pub mod engine; pub mod logic; pub mod tech_collective; pub mod treasury; -pub mod vesting; diff --git a/runtime/tests/governance/vesting.rs b/runtime/tests/governance/vesting.rs deleted file mode 100644 index 02f7700f..00000000 --- a/runtime/tests/governance/vesting.rs +++ /dev/null @@ -1,619 +0,0 @@ -#[cfg(test)] -mod tests { - use crate::common::TestCommons; - use codec::Encode; - use frame_support::{ - assert_ok, - traits::{Bounded, Currency, VestingSchedule}, - }; - use pallet_conviction_voting::{AccountVote, Vote}; - use pallet_vesting::VestingInfo; - use quantus_runtime::{ - Balances, ConvictionVoting, Preimage, Referenda, RuntimeCall, RuntimeOrigin, System, - Utility, Vesting, DAYS, UNIT, - }; - use sp_runtime::{ - traits::{BlakeTwo256, Hash}, - MultiAddress, - }; - - /// Test case: Grant application through referendum with vesting payment schedule - /// - /// Scenario: - /// 1. Grant proposal submitted for referendum voting (treasury track) - /// 2. After positive voting, treasury spend is approved and executed - /// 3. Separate vesting implementation follows (two-stage governance pattern) - #[test] - fn test_grant_application_with_vesting_schedule() { - TestCommons::new_fast_governance_test_ext().execute_with(|| { - // Setup accounts - let proposer = TestCommons::account_id(1); - let beneficiary = TestCommons::account_id(2); - let voter1 = TestCommons::account_id(3); - let voter2 = TestCommons::account_id(4); - - // Give voters some balance for voting - Balances::make_free_balance_be(&voter1, 1000 * UNIT); - Balances::make_free_balance_be(&voter2, 1000 * UNIT); - Balances::make_free_balance_be(&proposer, 10000 * UNIT); // Proposer needs more funds for vesting transfer - - // Step 1: Create a treasury proposal for referendum - let grant_amount = 1000 * UNIT; - let vesting_period = 30; // Fast test: 30 blocks instead of 30 days - let per_block = grant_amount / vesting_period as u128; - - // Create the vesting info for later implementation - let vesting_info = VestingInfo::new(grant_amount, per_block, 1); - - // Treasury call for referendum approval - let treasury_call = RuntimeCall::TreasuryPallet(pallet_treasury::Call::spend { - asset_kind: Box::new(()), - amount: grant_amount, - beneficiary: Box::new(MultiAddress::Id(beneficiary.clone())), - valid_from: None, - }); - - // Note: Two-stage process - referendum approves principle, implementation follows - let _vesting_call = RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(beneficiary.clone()), - schedule: vesting_info, - }); - - // Two-stage governance flow: referendum approves treasury spend principle - // Implementation details (like vesting schedule) handled in separate execution phase - let referendum_call = treasury_call; - - // Step 2: Submit preimage for the referendum call - let encoded_proposal = referendum_call.encode(); - let preimage_hash = BlakeTwo256::hash(&encoded_proposal); - - assert_ok!(Preimage::note_preimage( - RuntimeOrigin::signed(proposer.clone()), - encoded_proposal.clone() - )); - - // Step 3: Submit referendum for treasury spending (using treasury track) - let bounded_call = - Bounded::Lookup { hash: preimage_hash, len: encoded_proposal.len() as u32 }; - assert_ok!(Referenda::submit( - RuntimeOrigin::signed(proposer.clone()), - Box::new( - quantus_runtime::governance::pallet_custom_origins::Origin::SmallSpender.into() - ), - bounded_call, - frame_support::traits::schedule::DispatchTime::After(1) - )); - - // Step 4: Vote on referendum - let referendum_index = 0; - - // Vote YES with conviction - assert_ok!(ConvictionVoting::vote( - RuntimeOrigin::signed(voter1.clone()), - referendum_index, - AccountVote::Standard { - vote: Vote { - aye: true, - conviction: pallet_conviction_voting::Conviction::Locked1x, - }, - balance: 500 * UNIT, - } - )); - - assert_ok!(ConvictionVoting::vote( - RuntimeOrigin::signed(voter2.clone()), - referendum_index, - AccountVote::Standard { - vote: Vote { - aye: true, - conviction: pallet_conviction_voting::Conviction::Locked2x, - }, - balance: 300 * UNIT, - } - )); - - // Step 5: Wait for referendum to pass and execute - // Fast forward blocks for voting period + confirmation period (using fast governance - // timing) - let blocks_to_advance = 2 + 2 + 2 + 2 + 1; // prepare + decision + confirm + enactment + 1 - TestCommons::run_to_block(System::block_number() + blocks_to_advance); - - // The referendum should now be approved and treasury spend executed - - // Step 6: Implementation phase - after referendum approval, implement with vesting - // This demonstrates a realistic two-stage governance pattern: - // 1. Community votes on grant approval (principle) - // 2. Treasury council/governance implements with appropriate safeguards (vesting) - // This separation allows for community input on allocation while maintaining - // implementation flexibility - - println!("Referendum approved treasury spend. Now implementing vesting..."); - - // Implementation of the approved grant with vesting schedule - // This would typically be done by treasury council or automated system - assert_ok!(Vesting::force_vested_transfer( - RuntimeOrigin::root(), - MultiAddress::Id(proposer.clone()), - MultiAddress::Id(beneficiary.clone()), - vesting_info, - )); - - let initial_balance = Balances::free_balance(&beneficiary); - let locked_balance = Vesting::vesting_balance(&beneficiary).unwrap_or(0); - - println!("Beneficiary balance: {:?}", initial_balance); - println!("Locked balance: {:?}", locked_balance); - - assert!(locked_balance > 0, "Vesting should have been created"); - - // Step 7: Test vesting unlock over time - let initial_block = System::block_number(); - let initial_locked_amount = locked_balance; // Save the initial locked amount - - // Check initial state - println!("Initial balance: {:?}", initial_balance); - println!("Initial locked: {:?}", locked_balance); - println!("Initial block: {:?}", initial_block); - - // Fast forward a few blocks and check unlocking - TestCommons::run_to_block(initial_block + 10); - - // Check after some blocks - let mid_balance = Balances::free_balance(&beneficiary); - let mid_locked = Vesting::vesting_balance(&beneficiary).unwrap_or(0); - - println!("Mid balance: {:?}", mid_balance); - println!("Mid locked: {:?}", mid_locked); - - // The test should pass if vesting is working correctly - // mid_locked should be less than the initial locked amount - assert!( - mid_locked < initial_locked_amount, - "Some funds should be unlocked over time: initial_locked={:?}, mid_locked={:?}", - initial_locked_amount, - mid_locked - ); - - // Fast-forward to end of vesting period - TestCommons::run_to_block(initial_block + vesting_period + 1); - - // All funds should be unlocked - let final_balance = Balances::free_balance(&beneficiary); - let final_locked = Vesting::vesting_balance(&beneficiary).unwrap_or(0); - - println!("Final balance: {:?}", final_balance); - println!("Final locked: {:?}", final_locked); - - assert_eq!(final_locked, 0, "All funds should be unlocked"); - // Note: In the vesting pallet, when funds are fully vested, they become available - // but the balance might not increase if the initial transfer was part of the vesting - // The main assertion is that the vesting worked correctly (final_locked == 0) - println!("Vesting test completed successfully - funds are fully unlocked"); - }); - } - - /// Test case: Multi-milestone grant with multiple vesting schedules - /// - /// Scenario: Grant paid out in multiple tranches (milestones) - /// after achieving specific goals - #[test] - fn test_milestone_based_grant_with_multiple_vesting() { - TestCommons::new_fast_governance_test_ext().execute_with(|| { - let grantee = TestCommons::account_id(1); - let grantor = TestCommons::account_id(2); - - Balances::make_free_balance_be(&grantor, 10000 * UNIT); - - // Atomic milestone funding: all operations succeed or fail together - let milestone1_amount = 300 * UNIT; - let milestone2_amount = 400 * UNIT; - let milestone3_amount = 300 * UNIT; - - let milestone1_vesting = VestingInfo::new(milestone1_amount, milestone1_amount / 30, 1); - let milestone2_vesting = - VestingInfo::new(milestone2_amount, milestone2_amount / 60, 31); - - // Create batch call for all milestone operations - let _milestone_batch = RuntimeCall::Utility(pallet_utility::Call::batch_all { - calls: vec![ - // Milestone 1: Initial funding with short vesting - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: milestone1_vesting, - }), - // Milestone 2: Mid-term funding with longer vesting - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: milestone2_vesting, - }), - // Milestone 3: Immediate payment - RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { - dest: MultiAddress::Id(grantee.clone()), - value: milestone3_amount, - }), - ], - }); - - // Execute all milestones atomically - let calls = vec![ - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: milestone1_vesting, - }), - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: milestone2_vesting, - }), - RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { - dest: MultiAddress::Id(grantee.clone()), - value: milestone3_amount, - }), - ]; - assert_ok!(Utility::batch_all(RuntimeOrigin::signed(grantor.clone()), calls)); - - // Check that multiple vesting schedules are active - let vesting_schedules = Vesting::vesting(grantee.clone()).unwrap(); - assert_eq!(vesting_schedules.len(), 2, "Should have 2 active vesting schedules"); - - // Fast forward and verify unlocking patterns - TestCommons::run_to_block(40); // Past first vesting period - - let balance_after_first = Balances::free_balance(&grantee); - assert!( - balance_after_first >= milestone1_amount + milestone3_amount, - "First milestone and immediate payment should be available" - ); - - // Fast forward past second vesting period - TestCommons::run_to_block(100); - - let final_balance = Balances::free_balance(&grantee); - let expected_total = milestone1_amount + milestone2_amount + milestone3_amount; - assert!(final_balance >= expected_total, "All grant funds should be available"); - }); - } - - /// Test case: Realistic grant process with Tech Collective milestone evaluation - /// - /// Scenario: - /// 1. Initial referendum approves entire grant plan - /// 2. For each milestone: grantee delivers proof → Tech Collective votes via referenda → - /// payment released - /// 3. Tech Collective determines vesting schedule based on milestone quality/risk assessment - #[test] - fn test_progressive_milestone_referenda() { - TestCommons::new_fast_governance_test_ext().execute_with(|| { - let grantee = TestCommons::account_id(1); - let proposer = TestCommons::account_id(2); - let voter1 = TestCommons::account_id(3); - let voter2 = TestCommons::account_id(4); - - // Tech Collective members - technical experts who evaluate milestones - let tech_member1 = TestCommons::account_id(5); - let tech_member2 = TestCommons::account_id(6); - let tech_member3 = TestCommons::account_id(7); - let treasury_account = TestCommons::account_id(8); - - // Setup balances for governance participation - Balances::make_free_balance_be(&voter1, 2000 * UNIT); - Balances::make_free_balance_be(&voter2, 2000 * UNIT); - Balances::make_free_balance_be(&proposer, 15000 * UNIT); - Balances::make_free_balance_be(&tech_member1, 3000 * UNIT); - Balances::make_free_balance_be(&tech_member2, 3000 * UNIT); - Balances::make_free_balance_be(&tech_member3, 3000 * UNIT); - Balances::make_free_balance_be(&treasury_account, 10000 * UNIT); - - // Add Tech Collective members - assert_ok!(quantus_runtime::TechCollective::add_member( - RuntimeOrigin::root(), - MultiAddress::Id(tech_member1.clone()) - )); - assert_ok!(quantus_runtime::TechCollective::add_member( - RuntimeOrigin::root(), - MultiAddress::Id(tech_member2.clone()) - )); - assert_ok!(quantus_runtime::TechCollective::add_member( - RuntimeOrigin::root(), - MultiAddress::Id(tech_member3.clone()) - )); - - let milestone1_amount = 400 * UNIT; - let milestone2_amount = 500 * UNIT; - let milestone3_amount = 600 * UNIT; - let total_grant = milestone1_amount + milestone2_amount + milestone3_amount; - - // === STEP 1: Initial referendum approves entire grant plan === - println!("=== REFERENDUM: Grant Plan Approval ==="); - - let grant_approval_call = RuntimeCall::TreasuryPallet(pallet_treasury::Call::spend { - asset_kind: Box::new(()), - amount: total_grant, - beneficiary: Box::new(MultiAddress::Id(treasury_account.clone())), - valid_from: None, - }); - - let encoded_proposal = grant_approval_call.encode(); - let preimage_hash = BlakeTwo256::hash(&encoded_proposal); - - assert_ok!(Preimage::note_preimage( - RuntimeOrigin::signed(proposer.clone()), - encoded_proposal.clone() - )); - - let bounded_call = - Bounded::Lookup { hash: preimage_hash, len: encoded_proposal.len() as u32 }; - assert_ok!(Referenda::submit( - RuntimeOrigin::signed(proposer.clone()), - Box::new( - quantus_runtime::governance::pallet_custom_origins::Origin::SmallSpender.into() - ), - bounded_call, - frame_support::traits::schedule::DispatchTime::After(1) - )); - - // Community votes on the grant plan - assert_ok!(ConvictionVoting::vote( - RuntimeOrigin::signed(voter1.clone()), - 0, - AccountVote::Standard { - vote: Vote { - aye: true, - conviction: pallet_conviction_voting::Conviction::Locked1x, - }, - balance: 800 * UNIT, - } - )); - - assert_ok!(ConvictionVoting::vote( - RuntimeOrigin::signed(voter2.clone()), - 0, - AccountVote::Standard { - vote: Vote { - aye: true, - conviction: pallet_conviction_voting::Conviction::Locked2x, - }, - balance: 600 * UNIT, - } - )); - - let blocks_to_advance = 2 + 2 + 2 + 2 + 1; // Fast governance timing: prepare + decision + confirm + enactment + 1 - TestCommons::run_to_block(System::block_number() + blocks_to_advance); - - println!("✅ Grant plan approved by referendum!"); - - // === STEP 2: Tech Collective milestone evaluations via referenda === - - // === MILESTONE 1: Tech Collective Decision === - println!("=== MILESTONE 1: Tech Collective Decision ==="); - - println!("📋 Grantee delivers milestone 1: Basic protocol implementation"); - TestCommons::run_to_block(System::block_number() + 10); - - // Tech Collective evaluates and decides on milestone 1 payment - let milestone1_vesting = VestingInfo::new( - milestone1_amount, - milestone1_amount / 60, // Fast test: 60 blocks instead of 60 days - System::block_number() + 1, - ); - - println!("🔍 Tech Collective evaluates milestone 1..."); - - // Tech Collective implements milestone payment directly (as technical body with - // authority) In practice this could be through their own governance or automated - // after technical review - assert_ok!(Vesting::force_vested_transfer( - RuntimeOrigin::root(), /* Tech Collective has root-level authority for technical - * decisions */ - MultiAddress::Id(treasury_account.clone()), - MultiAddress::Id(grantee.clone()), - milestone1_vesting, - )); - - println!("✅ Tech Collective approved milestone 1 with 60-day vesting"); - - let milestone1_locked = Vesting::vesting_balance(&grantee).unwrap_or(0); - println!("Grantee locked (vesting): {:?}", milestone1_locked); - assert!(milestone1_locked > 0, "Milestone 1 should be vesting"); - - // === MILESTONE 2: Tech Collective Decision === - println!("=== MILESTONE 2: Tech Collective Decision ==="); - - TestCommons::run_to_block(System::block_number() + 20); - println!("📋 Grantee delivers milestone 2: Advanced features + benchmarks"); - - // Reduced vesting due to high quality - let milestone2_vesting = VestingInfo::new( - milestone2_amount, - milestone2_amount / 30, // Fast test: 30 blocks instead of 30 days - System::block_number() + 1, - ); - - println!("🔍 Tech Collective evaluates milestone 2 (high quality work)..."); - - // Tech Collective approves with reduced vesting due to excellent work - assert_ok!(Vesting::force_vested_transfer( - RuntimeOrigin::root(), - MultiAddress::Id(treasury_account.clone()), - MultiAddress::Id(grantee.clone()), - milestone2_vesting, - )); - - println!("✅ Tech Collective approved milestone 2 with reduced 30-day vesting"); - - // === MILESTONE 3: Final Tech Collective Decision === - println!("=== MILESTONE 3: Final Tech Collective Decision ==="); - - TestCommons::run_to_block(System::block_number() + 20); - println!( - "📋 Grantee delivers final milestone: Production deployment + maintenance plan" - ); - - println!("🔍 Tech Collective evaluates final milestone (project completion)..."); - - // Immediate payment for completed project - no vesting needed - assert_ok!(Balances::transfer_allow_death( - RuntimeOrigin::signed(treasury_account.clone()), - MultiAddress::Id(grantee.clone()), - milestone3_amount, - )); - - println!("✅ Tech Collective approved final milestone with immediate payment"); - - // === Verify Tech Collective governance worked === - let final_balance = Balances::free_balance(&grantee); - let remaining_locked = Vesting::vesting_balance(&grantee).unwrap_or(0); - - println!("Final grantee balance: {:?}", final_balance); - println!("Remaining locked: {:?}", remaining_locked); - - let vesting_schedules = Vesting::vesting(grantee.clone()).unwrap_or_default(); - assert!( - !vesting_schedules.is_empty(), - "Should have active vesting schedules from Tech Collective decisions" - ); - - assert!( - final_balance >= milestone3_amount, - "Tech Collective milestone process should have provided controlled funding" - ); - - println!("🎉 Tech Collective governance process completed successfully!"); - println!(" - One community referendum approved the overall grant plan"); - println!(" - Tech Collective evaluated each milestone with technical expertise"); - println!(" - Vesting schedules determined by technical quality assessment:"); - println!(" * Milestone 1: 60-day vesting (conservative, early stage)"); - println!(" * Milestone 2: 30-day vesting (high confidence, quality work)"); - println!(" * Milestone 3: Immediate payment (project completed successfully)"); - }); - } - - /// Test case: Treasury proposal with automatic vesting integration - /// - /// Scenario: Treasury spend and vesting creation executed atomically - /// through batch calls for integrated fund management - #[test] - fn test_treasury_auto_vesting_integration() { - TestCommons::new_fast_governance_test_ext().execute_with(|| { - let beneficiary = TestCommons::account_id(1); - let amount = 1000 * UNIT; - - // Create atomic treasury spend + vesting creation through batch calls - let vesting_info = VestingInfo::new(amount, amount / (30 * DAYS) as u128, 1); - - let _treasury_vesting_batch = RuntimeCall::Utility(pallet_utility::Call::batch_all { - calls: vec![ - // Treasury spend - RuntimeCall::TreasuryPallet(pallet_treasury::Call::spend { - asset_kind: Box::new(()), - amount, - beneficiary: Box::new(MultiAddress::Id(beneficiary.clone())), - valid_from: None, - }), - // Vesting creation as part of same atomic transaction - RuntimeCall::Vesting(pallet_vesting::Call::force_vested_transfer { - source: MultiAddress::Id(beneficiary.clone()), /* Simplified - in - * practice treasury - * account */ - target: MultiAddress::Id(beneficiary.clone()), - schedule: vesting_info, - }), - ], - }); - - // Execute atomic treasury spend + vesting batch - let calls = vec![ - RuntimeCall::TreasuryPallet(pallet_treasury::Call::spend { - asset_kind: Box::new(()), - amount, - beneficiary: Box::new(MultiAddress::Id(beneficiary.clone())), - valid_from: None, - }), - RuntimeCall::Vesting(pallet_vesting::Call::force_vested_transfer { - source: MultiAddress::Id(beneficiary.clone()), - target: MultiAddress::Id(beneficiary.clone()), - schedule: vesting_info, - }), - ]; - assert_ok!(Utility::batch_all(RuntimeOrigin::root(), calls)); - - // Verify the integration worked - let locked_amount = Vesting::vesting_balance(&beneficiary).unwrap_or(0); - assert!(locked_amount > 0, "Vesting should be active"); - }); - } - - /// Test case: Emergency vesting operations with batch calls - /// - /// Scenario: Emergency handling of vesting schedules through - /// atomic batch operations for intervention scenarios - #[test] - fn test_emergency_vesting_cancellation() { - TestCommons::new_fast_governance_test_ext().execute_with(|| { - let grantee = TestCommons::account_id(1); - let grantor = TestCommons::account_id(2); - - Balances::make_free_balance_be(&grantor, 2000 * UNIT); - - // Create vesting schedule with atomic batch call setup - let total_amount = 1000 * UNIT; - let vesting_info = VestingInfo::new(total_amount, total_amount / 100, 1); - - // Example of comprehensive grant setup through batch operations - let _grant_batch = RuntimeCall::Utility(pallet_utility::Call::batch_all { - calls: vec![ - // Initial grant setup - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: vesting_info, - }), - // Could include additional setup calls (metadata, tracking, etc.) - ], - }); - - let calls = vec![RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: vesting_info, - })]; - assert_ok!(Utility::batch_all(RuntimeOrigin::signed(grantor.clone()), calls)); - - // Let some time pass and some funds unlock - TestCommons::run_to_block(50); - - let balance_before_cancellation = Balances::free_balance(&grantee); - let locked_before = Vesting::vesting_balance(&grantee).unwrap_or(0); - - assert!(locked_before > 0, "Should still have locked funds"); - - // Emergency intervention through atomic batch operations - let _emergency_batch = RuntimeCall::Utility(pallet_utility::Call::batch_all { - calls: vec![ - // Emergency action: schedule management operations - RuntimeCall::Vesting(pallet_vesting::Call::merge_schedules { - schedule1_index: 0, - schedule2_index: 0, - }), - // Could include additional emergency measures like fund recovery or - // notifications - ], - }); - - // Execute emergency intervention if vesting exists - if !Vesting::vesting(grantee.clone()).unwrap().is_empty() { - let calls = vec![RuntimeCall::Vesting(pallet_vesting::Call::merge_schedules { - schedule1_index: 0, - schedule2_index: 0, - })]; - assert_ok!(Utility::batch_all(RuntimeOrigin::signed(grantee.clone()), calls)); - } - - let balance_after = Balances::free_balance(&grantee); - - // Verify that emergency operations maintained system integrity - // (In practice, this would involve more sophisticated intervention mechanisms) - assert!( - balance_after >= balance_before_cancellation, - "Emergency handling should maintain or improve user's position" - ); - }); - } -} From f7f373c951c832adba0fe063964c59bc0afa8c8c Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Fri, 16 Jan 2026 10:44:57 +0800 Subject: [PATCH 03/27] poc: First multisig version --- Cargo.lock | 19 + Cargo.toml | 2 + pallets/multisig/Cargo.toml | 61 ++ pallets/multisig/README.md | 293 ++++++ pallets/multisig/src/benchmarking.rs | 427 +++++++++ pallets/multisig/src/lib.rs | 872 ++++++++++++++++++ pallets/multisig/src/mock.rs | 132 +++ pallets/multisig/src/tests.rs | 1233 ++++++++++++++++++++++++++ pallets/multisig/src/weights.rs | 246 +++++ runtime/Cargo.toml | 3 + runtime/src/benchmarks.rs | 1 + runtime/src/configs/mod.rs | 28 + runtime/src/lib.rs | 3 + 13 files changed, 3320 insertions(+) create mode 100644 pallets/multisig/Cargo.toml create mode 100644 pallets/multisig/README.md create mode 100644 pallets/multisig/src/benchmarking.rs create mode 100644 pallets/multisig/src/lib.rs create mode 100644 pallets/multisig/src/mock.rs create mode 100644 pallets/multisig/src/tests.rs create mode 100644 pallets/multisig/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index a5147e1b..9f186480 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7170,6 +7170,24 @@ dependencies = [ "sp-mmr-primitives", ] +[[package]] +name = "pallet-multisig" +version = "1.0.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "pallet-balances 40.0.1", + "pallet-timestamp", + "parity-scale-codec", + "scale-info", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", +] + [[package]] name = "pallet-preimage" version = "41.0.0" @@ -9158,6 +9176,7 @@ dependencies = [ "pallet-balances 40.0.1", "pallet-conviction-voting", "pallet-mining-rewards", + "pallet-multisig", "pallet-preimage", "pallet-qpow", "pallet-ranked-collective", diff --git a/Cargo.toml b/Cargo.toml index bd8bcfd9..a4c9f0a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "node", "pallets/balances", "pallets/mining-rewards", + "pallets/multisig", "pallets/qpow", "pallets/reversible-transfers", "pallets/scheduler", @@ -131,6 +132,7 @@ zeroize = { version = "1.7.0", default-features = false } # Own dependencies pallet-balances = { path = "./pallets/balances", default-features = false } pallet-mining-rewards = { path = "./pallets/mining-rewards", default-features = false } +pallet-multisig = { path = "./pallets/multisig", default-features = false } pallet-qpow = { path = "./pallets/qpow", default-features = false } pallet-reversible-transfers = { path = "./pallets/reversible-transfers", default-features = false } pallet-scheduler = { path = "./pallets/scheduler", default-features = false } diff --git a/pallets/multisig/Cargo.toml b/pallets/multisig/Cargo.toml new file mode 100644 index 00000000..a63c2b9c --- /dev/null +++ b/pallets/multisig/Cargo.toml @@ -0,0 +1,61 @@ +[package] +authors.workspace = true +description = "Multisig pallet for Quantus" +edition.workspace = true +homepage.workspace = true +license = "Apache-2.0" +name = "pallet-multisig" +repository.workspace = true +version = "1.0.0" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { features = ["derive", "max-encoded-len"], workspace = true } +frame-benchmarking = { optional = true, workspace = true } +frame-support.workspace = true +frame-system.workspace = true +log.workspace = true +pallet-balances.workspace = true +scale-info = { features = ["derive"], workspace = true } +sp-arithmetic.workspace = true +sp-runtime.workspace = true +sp-io.workspace = true +sp-core.workspace = true + +[dev-dependencies] +frame-support = { workspace = true, features = ["experimental"], default-features = true } +pallet-balances = { workspace = true, features = ["std"] } +pallet-timestamp.workspace = true +sp-core.workspace = true +sp-io.workspace = true + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "log/std", + "pallet-balances/std", + "pallet-timestamp/std", + "scale-info/std", + "sp-core/std", + "sp-io/std", + "sp-runtime/std", + "sp-arithmetic/std", +] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "sp-runtime/try-runtime", +] diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md new file mode 100644 index 00000000..938eb69a --- /dev/null +++ b/pallets/multisig/README.md @@ -0,0 +1,293 @@ +# Multisig Pallet + +A multisignature wallet pallet for the Quantus blockchain with an economic security model. + +## Overview + +This pallet provides functionality for creating and managing multisig accounts that require multiple approvals before executing transactions. It implements a dual fee+deposit system for spam prevention and storage cleanup mechanisms with grace periods. + +## Core Functionality + +### 1. Create Multisig +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()) + +**Validation:** +- No duplicate signers +- Threshold must be > 0 +- Threshold cannot exceed number of signers +- Signers count must be ≤ MaxSigners + +**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 + +**Economic Costs:** +- **MultisigFee**: 100 MILLI_UNIT (non-refundable, burned immediately) +- **MultisigDeposit**: 100 MILLI_UNIT (refundable after grace period when multisig becomes inactive) + +### 2. Propose Transaction +Creates a new proposal for multisig execution. + +**Required Parameters:** +- `multisig_address: AccountId` - Target multisig account (REQUIRED) +- `call: Vec` - Encoded RuntimeCall to execute (REQUIRED, max MaxCallSize bytes) +- `expiry: BlockNumber` - Deadline for collecting approvals (REQUIRED) + +**Validation:** +- Caller must be a signer +- Call size must be ≤ MaxCallSize +- Multisig cannot have more than MaxActiveProposals open proposals +- Expiry must be in the future (current_block < expiry) + +**Economic Costs:** +- **ProposalFee**: 1000 MILLI_UNIT (non-refundable, burned immediately) +- **ProposalDeposit**: 1000 MILLI_UNIT (refundable when proposal executed/cancelled/removed) + +**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. + +**Required Parameters:** +- `multisig_address: AccountId` - Target multisig (REQUIRED) +- `proposal_hash: Hash` - Hash of proposal to approve (REQUIRED) + +**Validation:** +- Caller must be a signer +- Proposal must exist +- Proposal must not be expired (current_block ≤ expiry) +- Caller must not have already approved + +**Economic Costs:** None (only transaction fees) + +### 4. Execute Transaction +Executes a proposal once threshold is met. + +**Required Parameters:** +- `multisig_address: AccountId` - Target multisig (REQUIRED) +- `proposal_hash: Hash` - Hash of proposal to execute (REQUIRED) + +**Validation:** +- Proposal must exist +- Number of approvals must be ≥ threshold +- No expiry check (can execute even after expiry if threshold was met before) + +**Economic Effects:** +- ProposalDeposit returned to proposer +- Proposal removed from storage +- Encoded call executed as multisig_address origin + +**Economic Costs:** None (deposit returned) + +### 5. Cancel Transaction +Cancels a proposal (proposer only). + +**Required Parameters:** +- `multisig_address: AccountId` - Target multisig (REQUIRED) +- `proposal_hash: Hash` - Hash of proposal to cancel (REQUIRED) + +**Validation:** +- Caller must be the proposer +- Proposal must exist + +**Economic Effects:** +- ProposalDeposit returned to proposer +- Proposal removed from storage + +**Economic Costs:** None (deposit returned) + +**Note:** ProposalFee is NOT refunded - it was burned at proposal creation. + +### 6. Remove Expired +Removes expired proposals from storage (cleanup mechanism). + +**Required Parameters:** +- `multisig_address: AccountId` - Target multisig (REQUIRED) +- `proposal_hash: Hash` - Hash of expired proposal (REQUIRED) + +**Validation:** +- Proposal must exist +- Proposal must be expired (current_block > expiry) +- Within grace period (expiry < current_block ≤ expiry + GracePeriod): only proposer can remove +- After grace period (current_block > expiry + GracePeriod): anyone can remove + +**Economic Effects:** +- ProposalDeposit returned to proposer (even if removed by someone else) +- Proposal removed from storage + +**Economic Costs:** None (deposit always returned to proposer) + +### 7. Claim Deposits +Batch cleanup operation to recover all eligible deposits. + +**Required Parameters:** +- `multisig_address: AccountId` - Target multisig (REQUIRED) + +**Validation:** +- Only cleans proposals where caller is proposer +- Only processes proposals past grace period (current_block > expiry + GracePeriod) +- Only removes multisig if inactive (current_block > last_activity + GracePeriod) and no active proposals + +**Economic Effects:** +- Returns all eligible proposal deposits to caller +- If multisig is inactive: returns MultisigDeposit to creator and removes multisig +- Removes all eligible proposals from storage + +**Economic Costs:** None (only returns deposits) + +## Economic Model + +### Fees (Non-refundable) +Burned immediately upon payment, never returned: +- **MultisigFee**: 100 MILLI_UNIT - paid on multisig creation +- **ProposalFee**: 1000 MILLI_UNIT - paid on proposal creation + +### Deposits (Refundable) +Reserved and returned under specific conditions: +- **MultisigDeposit**: 100 MILLI_UNIT - returned after grace period when multisig inactive +- **ProposalDeposit**: 1000 MILLI_UNIT - returned on execute/cancel/remove_expired + +### Grace Period +- **GracePeriod**: 28,800 blocks (~2 days with 6s blocks) +- Applies to proposals: after expiry + grace, anyone can cleanup +- Applies to multisigs: after last_activity + grace, deposit can be claimed +- Ensures proposers have time to cleanup before public cleanup + +### Storage Limits +- **MaxSigners**: 10 - Maximum signers per multisig +- **MaxActiveProposals**: 100 - Maximum open proposals per multisig at once +- **MaxCallSize**: 1024 bytes - Maximum encoded call size + +## Storage + +### Multisigs: Map +Stores multisig account data: +```rust +MultisigData { + signers: BoundedVec, // List of authorized signers + 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 (for MaxActiveProposals check) +} +``` + +### Proposals: DoubleMap +Stores proposal data indexed by (multisig_address, proposal_hash): +```rust +ProposalData { + proposer: AccountId, // Who proposed (receives deposit back) + call: BoundedVec, // Encoded RuntimeCall to execute + expiry: BlockNumber, // Deadline for approvals + approvals: BoundedVec, // List of signers who approved + deposit: Balance, // Reserved deposit (refundable) +} +``` + +### GlobalNonce: u64 +Internal counter for generating unique multisig addresses. Not exposed via API. + +## Events + +- `MultisigCreated { creator, multisig_address, signers, threshold, nonce }` +- `TransactionProposed { multisig_address, proposer, proposal_hash }` +- `TransactionApproved { multisig_address, approver, proposal_hash, approvals_count }` +- `TransactionExecuted { multisig_address, proposal_hash, result }` +- `TransactionCancelled { multisig_address, proposer, proposal_hash }` +- `ProposalRemoved { multisig_address, proposal_hash, proposer, removed_by, in_grace_period }` +- `DepositsClaimed { multisig_address, claimer, total_returned, proposals_removed, multisig_removed }` + +## Errors + +- `NotEnoughSigners` - Less than 1 signer provided +- `ThresholdZero` - Threshold cannot be 0 +- `ThresholdTooHigh` - Threshold exceeds number of signers +- `TooManySigners` - Exceeds MaxSigners limit +- `DuplicateSigner` - Duplicate address in signers list +- `MultisigAlreadyExists` - Multisig with this address already exists +- `MultisigNotFound` - Multisig does not exist +- `NotASigner` - Caller is not authorized signer +- `ProposalNotFound` - Proposal does not exist +- `NotProposer` - Caller is not the proposer (for cancel) +- `AlreadyApproved` - Signer already approved this proposal +- `NotEnoughApprovals` - Threshold not met (for execute) +- `ProposalExpired` - Proposal deadline passed (for approve) +- `CallTooLarge` - Encoded call exceeds MaxCallSize +- `InvalidCall` - Call decoding failed (for execute) +- `InsufficientBalance` - Not enough funds for fee/deposit +- `TooManyActiveProposals` - Multisig has MaxActiveProposals open proposals +- `ProposalNotExpired` - Proposal not yet expired (for remove_expired) +- `GracePeriodNotElapsed` - Grace period not yet passed + +## Important Behavior + +### 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) + +**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) + +// To create another multisig with same signers: +create_multisig([alice, bob, charlie], 2) // → multisig_addr_2 (nonce=1, different address) +``` + +## Security Considerations + +### Spam Prevention +- Fees (non-refundable) prevent proposal spam +- Deposits (refundable) prevent storage bloat +- MaxActiveProposals limits per-multisig open proposals + +### Storage Cleanup +- Grace period allows proposers priority cleanup +- After grace: public cleanup incentivized +- Batch cleanup via claim_deposits for efficiency + +### Economic Attacks +- Creating spam multisigs costs 100 MILLI_UNIT (burned) +- Creating spam proposals costs 1000 MILLI_UNIT (burned) + 1000 MILLI_UNIT (locked) +- No limit on number of multisigs per user +- No global limits - only per-multisig limits + +### Call Execution +- Calls execute with multisig_address as origin +- Multisig can call ANY pallet (including recursive multisig calls) +- Call validation happens at execution time +- Failed calls emit event with error but don't revert proposal removal + +## Configuration Example + +```rust +impl pallet_multisig::Config for Runtime { + type RuntimeCall = RuntimeCall; + type Currency = Balances; + type MaxSigners = ConstU32<10>; + type MaxActiveProposals = ConstU32<100>; + type MaxCallSize = ConstU32<1024>; + type MultisigDeposit = ConstU128<{ 100 * MILLI_UNIT }>; + type MultisigFee = ConstU128<{ 100 * MILLI_UNIT }>; + type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; + type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; + type GracePeriod = ConstU32<28800>; // ~2 days + type PalletId = ConstPalletId(*b"py/mltsg"); + type WeightInfo = pallet_multisig::weights::SubstrateWeight; +} +``` + +## License + +Apache-2.0 diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs new file mode 100644 index 00000000..1b3ee940 --- /dev/null +++ b/pallets/multisig/src/benchmarking.rs @@ -0,0 +1,427 @@ +//! Benchmarking setup for pallet-multisig + +use super::*; +use crate::Pallet as Multisig; +use alloc::vec; +use frame_benchmarking::{account as benchmark_account, v2::*, BenchmarkError}; +use frame_support::traits::fungible::Mutate; +use frame_system::RawOrigin; +use sp_runtime::traits::Hash; + +const SEED: u32 = 0; + +// Helper to fund an account +type BalanceOf2 = ::Balance; + +fn fund_account(account: &T::AccountId, amount: BalanceOf2) +where + T: Config + pallet_balances::Config, +{ + let _ = as Mutate>::mint_into( + account, + amount * as frame_support::traits::Currency>::minimum_balance(), + ); +} + +#[benchmarks( + where + T: Config + pallet_balances::Config, + BalanceOf2: From, +)] +mod benchmarks { + use super::*; + use codec::Encode; + + #[benchmark] + fn create_multisig() -> 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 threshold = 2u32; + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), signers.clone(), threshold); + + // 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); + assert!(Multisigs::::contains_key(multisig_address)); + + Ok(()) + } + + #[benchmark] + fn propose() -> Result<(), BenchmarkError> { + // Setup: Create a multisig first + 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); + fund_account::(&signer1, BalanceOf2::::from(10000u128)); + fund_account::(&signer2, BalanceOf2::::from(10000u128)); + + let signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let threshold = 2u32; + + Multisig::::create_multisig( + RawOrigin::Signed(caller.clone()).into(), + signers.clone(), + threshold, + )?; + + // 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); + + // Create a simple call + let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; + let call = ::RuntimeCall::from(system_call); + let encoded_call = call.encode(); + let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); + + // Verify proposal was created + assert!(Proposals::::iter_key_prefix(&multisig_address).next().is_some()); + + Ok(()) + } + + #[benchmark] + fn approve() -> Result<(), BenchmarkError> { + // Setup: Create multisig and proposal directly in storage + 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); + fund_account::(&signer1, BalanceOf2::::from(10000u128)); + fund_account::(&signer2, BalanceOf2::::from(10000u128)); + + 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, 0); + let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); + let multisig_data = MultisigDataOf:: { + signers: bounded_signers, + threshold, + nonce: 0, + deposit: 100u32.into(), + creator: caller.clone(), + last_activity: frame_system::Pallet::::block_number(), + active_proposals: 1, + }; + Multisigs::::insert(&multisig_address, multisig_data); + + // Directly insert proposal into storage + let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; + 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_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(), + }; + + let proposal_hash = ::Hashing::hash_of(&( + multisig_address.clone(), + encoded_call, + )); + Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + + #[extrinsic_call] + _(RawOrigin::Signed(signer1.clone()), multisig_address.clone(), proposal_hash); + + // Verify approval was added + let proposal = Proposals::::get(&multisig_address, proposal_hash).unwrap(); + assert!(proposal.approvals.contains(&signer1)); + + Ok(()) + } + + #[benchmark] + fn execute() -> Result<(), BenchmarkError> { + // Setup: Create multisig and proposal with enough approvals directly in storage + 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); + fund_account::(&signer1, BalanceOf2::::from(10000u128)); + fund_account::(&signer2, BalanceOf2::::from(10000u128)); + + 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, 0); + let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); + let multisig_data = MultisigDataOf:: { + signers: bounded_signers, + threshold, + nonce: 0, + deposit: 100u32.into(), + creator: caller.clone(), + last_activity: frame_system::Pallet::::block_number(), + active_proposals: 1, + }; + Multisigs::::insert(&multisig_address, multisig_data); + + // Directly insert proposal with threshold approvals + // Use RuntimeCall instead of just frame_system::Call + let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; + 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(); + // Already has 2 approvals (threshold met) + let bounded_approvals: BoundedApprovalsOf = + vec![caller.clone(), signer1.clone()].try_into().unwrap(); + + let proposal_data = ProposalDataOf:: { + proposer: caller.clone(), + call: bounded_call, + expiry, + approvals: bounded_approvals, + deposit: 10u32.into(), + }; + + let proposal_hash = ::Hashing::hash_of(&( + multisig_address.clone(), + encoded_call, + )); + Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_hash); + + // Verify proposal was executed and removed + assert!(!Proposals::::contains_key(&multisig_address, proposal_hash)); + + 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(10000u128)); + + 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)); + + 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, 0); + let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); + let multisig_data = MultisigDataOf:: { + signers: bounded_signers, + threshold, + nonce: 0, + deposit: 100u32.into(), + creator: caller.clone(), + last_activity: frame_system::Pallet::::block_number(), + active_proposals: 1, + }; + Multisigs::::insert(&multisig_address, multisig_data); + + // Directly insert proposal into storage + let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; + 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_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(), + }; + + let proposal_hash = ::Hashing::hash_of(&( + multisig_address.clone(), + encoded_call, + )); + Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_hash); + + // Verify proposal was cancelled and removed + assert!(!Proposals::::contains_key(&multisig_address, proposal_hash)); + + 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 = 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)); + + 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, 0); + let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); + let multisig_data = MultisigDataOf:: { + signers: bounded_signers, + threshold, + nonce: 0, + deposit: 100u32.into(), + creator: caller.clone(), + last_activity: 1u32.into(), + active_proposals: 1, + }; + 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, + expiry, + approvals: bounded_approvals, + deposit: 10u32.into(), + }; + + let proposal_hash = ::Hashing::hash_of(&( + multisig_address.clone(), + encoded_call, + )); + Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + + // Move past expiry + grace period + frame_system::Pallet::::set_block_number(300u32.into()); + + // Call as proposer (caller) since we might still be in grace period + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_hash); + + // Verify proposal was removed + assert!(!Proposals::::contains_key(&multisig_address, proposal_hash)); + + Ok(()) + } + + #[benchmark] + fn claim_deposits() -> Result<(), BenchmarkError> { + // Setup: Create multisig with multiple expired proposals 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(10000u128)); + fund_account::(&signer2, BalanceOf2::::from(10000u128)); + + 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, 0); + let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); + let multisig_data = MultisigDataOf:: { + signers: bounded_signers, + threshold, + nonce: 0, + deposit: 100u32.into(), + creator: caller.clone(), + last_activity: 1u32.into(), + active_proposals: 5, + }; + Multisigs::::insert(&multisig_address, multisig_data); + + // Create multiple expired proposals directly in storage + let expiry = 10u32.into(); // Already expired + + for i in 0..5 { + 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(), + }; + + let proposal_hash = ::Hashing::hash_of(&( + multisig_address.clone(), + encoded_call, + )); + Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + } + + // Move past expiry + grace period + frame_system::Pallet::::set_block_number(300u32.into()); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone()); + + // Verify at least some proposals were cleaned up + // Note: claim_deposits only removes proposals past grace period + // Since we set block 300 and expiry was 10, and grace period might vary, + // we just verify the call succeeded + Ok(()) + } + + impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test); +} diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs new file mode 100644 index 00000000..472cd221 --- /dev/null +++ b/pallets/multisig/src/lib.rs @@ -0,0 +1,872 @@ +//! # Quantus Multisig Pallet +//! +//! This pallet provides multisignature functionality for managing shared accounts +//! that require multiple approvals before executing transactions. +//! +//! ## Features +//! +//! - Create multisig addresses with configurable thresholds +//! - Propose transactions for multisig approval +//! - Approve proposed transactions +//! - Execute transactions once threshold is reached +//! +//! ## Data Structures +//! +//! - **Multisig**: Contains signers, threshold, and global nonce +//! - **Proposal**: Contains transaction data, proposer, expiry, and approvals + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; +use alloc::vec::Vec; +pub use pallet::*; +pub use weights::*; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +pub mod weights; + +use codec::{Decode, Encode, MaxEncodedLen}; +use frame_support::{traits::Get, BoundedVec}; +use scale_info::TypeInfo; +use sp_runtime::RuntimeDebug; + +/// Multisig account data +#[derive(Encode, Decode, MaxEncodedLen, Clone, TypeInfo, RuntimeDebug, PartialEq, Eq)] +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 + pub nonce: u64, + /// Deposit required for storage (refundable after grace period) + pub deposit: Balance, + /// Account that created this multisig (receives deposit back) + pub creator: AccountId, + /// Last block when this multisig was used (for grace period calculation) + pub last_activity: BlockNumber, + /// Number of currently active (non-executed/non-cancelled) proposals + pub active_proposals: u32, +} + +impl Default + for MultisigData +{ + fn default() -> Self { + Self { + signers: Default::default(), + threshold: 1, + nonce: 0, + deposit: Default::default(), + creator: Default::default(), + last_activity: Default::default(), + active_proposals: 0, + } + } +} + +/// Proposal data +#[derive(Encode, Decode, MaxEncodedLen, Clone, TypeInfo, RuntimeDebug, PartialEq, Eq)] +pub struct ProposalData { + /// Account that proposed this transaction + pub proposer: AccountId, + /// The encoded call to be executed + pub call: BoundedCall, + /// Expiry block number + pub expiry: BlockNumber, + /// List of accounts that have approved this proposal + pub approvals: BoundedApprovals, + /// Deposit held for this proposal (returned on execute or cancel) + pub deposit: Balance, +} + +/// Balance type +type BalanceOf = <::Currency as frame_support::traits::Currency< + ::AccountId, +>>::Balance; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use codec::Encode; + use frame_support::{ + dispatch::{DispatchResult, GetDispatchInfo, PostDispatchInfo}, + pallet_prelude::*, + traits::{Currency, ReservableCurrency}, + PalletId, + }; + use frame_system::pallet_prelude::*; + use sp_arithmetic::traits::Saturating; + use sp_runtime::traits::{AccountIdConversion, Dispatchable, Hash}; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config>> { + /// The overarching call type + type RuntimeCall: Parameter + + Dispatchable + + GetDispatchInfo + + From> + + codec::Decode; + + /// Currency type for handling deposits + type Currency: Currency + ReservableCurrency; + + /// Maximum number of signers allowed in a multisig + #[pallet::constant] + type MaxSigners: Get; + + /// Maximum number of active (open) proposals per multisig at any given time + #[pallet::constant] + type MaxActiveProposals: Get; + + /// Maximum size of an encoded call + #[pallet::constant] + type MaxCallSize: Get; + + /// Deposit required per multisig account (refundable after grace period) + #[pallet::constant] + type MultisigDeposit: Get>; + + /// Fee charged for creating a multisig (non-refundable, paid always) + #[pallet::constant] + type MultisigFee: Get>; + + /// Deposit required per proposal (returned on execute or cancel) + #[pallet::constant] + type ProposalDeposit: Get>; + + /// Fee charged for creating a proposal (non-refundable, paid always) + #[pallet::constant] + type ProposalFee: Get>; + + /// Grace period after expiry when proposer can still recover deposit + /// After this period, anyone can remove the proposal and deposit is returned to proposer + #[pallet::constant] + type GracePeriod: Get>; + + /// Pallet ID for generating multisig addresses + #[pallet::constant] + type PalletId: Get; + + /// Weight information for extrinsics + type WeightInfo: WeightInfo; + } + + /// Type alias for bounded signers vector + pub type BoundedSignersOf = + BoundedVec<::AccountId, ::MaxSigners>; + + /// Type alias for bounded approvals vector + pub type BoundedApprovalsOf = + BoundedVec<::AccountId, ::MaxSigners>; + + /// Type alias for bounded call data + pub type BoundedCallOf = BoundedVec::MaxCallSize>; + + /// Type alias for MultisigData with proper bounds + pub type MultisigDataOf = MultisigData< + BalanceOf, + BlockNumberFor, + ::AccountId, + BoundedSignersOf, + >; + + /// Type alias for ProposalData with proper bounds + pub type ProposalDataOf = ProposalData< + ::AccountId, + BalanceOf, + BlockNumberFor, + BoundedCallOf, + BoundedApprovalsOf, + >; + + /// Global nonce for generating unique multisig addresses + #[pallet::storage] + pub type GlobalNonce = StorageValue<_, u64, ValueQuery>; + + /// Multisigs stored by their generated address + #[pallet::storage] + #[pallet::getter(fn multisigs)] + pub type Multisigs = + StorageMap<_, Blake2_128Concat, T::AccountId, MultisigDataOf, OptionQuery>; + + /// Proposals indexed by (multisig_address, proposal_hash) + #[pallet::storage] + #[pallet::getter(fn proposals)] + pub type Proposals = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + T::Hash, + ProposalDataOf, + OptionQuery, + >; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A new multisig account was created + /// [creator, multisig_address, signers, threshold, nonce] + MultisigCreated { + creator: T::AccountId, + multisig_address: T::AccountId, + signers: Vec, + threshold: u32, + nonce: u64, + }, + /// A transaction has been proposed + /// [multisig_address, proposer, proposal_hash] + TransactionProposed { + multisig_address: T::AccountId, + proposer: T::AccountId, + proposal_hash: T::Hash, + }, + /// A transaction has been approved + /// [multisig_address, approver, proposal_hash, approvals_count] + TransactionApproved { + multisig_address: T::AccountId, + approver: T::AccountId, + proposal_hash: T::Hash, + approvals_count: u32, + }, + /// A transaction has been executed + /// [multisig_address, proposal_hash, result] + TransactionExecuted { + multisig_address: T::AccountId, + proposal_hash: T::Hash, + result: DispatchResult, + }, + /// A transaction has been cancelled + /// [multisig_address, proposer, proposal_hash] + TransactionCancelled { + multisig_address: T::AccountId, + proposer: T::AccountId, + proposal_hash: T::Hash, + }, + /// Expired proposal was removed from storage + ProposalRemoved { + multisig_address: T::AccountId, + proposal_hash: T::Hash, + proposer: T::AccountId, + removed_by: T::AccountId, + in_grace_period: bool, + }, + /// Batch deposits claimed + DepositsClaimed { + multisig_address: T::AccountId, + claimer: T::AccountId, + total_returned: BalanceOf, + proposals_removed: u32, + multisig_removed: bool, + }, + } + + #[pallet::error] + pub enum Error { + /// Not enough signers provided + NotEnoughSigners, + /// Threshold must be greater than zero + ThresholdZero, + /// Threshold exceeds number of signers + ThresholdTooHigh, + /// Too many signers + TooManySigners, + /// Duplicate signer in list + DuplicateSigner, + /// Multisig already exists + MultisigAlreadyExists, + /// Multisig not found + MultisigNotFound, + /// Caller is not a signer of this multisig + NotASigner, + /// Proposal not found + ProposalNotFound, + /// Caller is not the proposer + NotProposer, + /// Already approved by this signer + AlreadyApproved, + /// Not enough approvals to execute + NotEnoughApprovals, + /// Proposal has expired + ProposalExpired, + /// Call data too large + CallTooLarge, + /// Failed to decode call data + InvalidCall, + /// Too many active proposals for this multisig + TooManyActiveProposals, + /// Insufficient balance for deposit + InsufficientBalance, + /// Proposal has active deposit + ProposalHasDeposit, + /// Proposal has not expired yet + ProposalNotExpired, + /// Grace period has not elapsed yet + GracePeriodNotElapsed, + } + + #[pallet::call] + impl Pallet { + /// Create a new multisig account + /// + /// Parameters: + /// - `signers`: List of accounts that can sign for this multisig + /// - `threshold`: Number of approvals required to execute transactions + /// + /// The multisig address is derived from a hash of all signers + global nonce. + /// The creator must pay: + /// - A fee (non-refundable, burned) + /// - A deposit (refundable after grace period of inactivity) + #[pallet::call_index(0)] + #[pallet::weight(::WeightInfo::create_multisig())] + pub fn create_multisig( + origin: OriginFor, + signers: Vec, + threshold: u32, + ) -> DispatchResult { + let creator = ensure_signed(origin)?; + + // Validate inputs + ensure!(threshold > 0, Error::::ThresholdZero); + ensure!(!signers.is_empty(), Error::::NotEnoughSigners); + 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) + let mut sorted_signers = signers.clone(); + sorted_signers.sort(); + + // Check for duplicate signers + for i in 1..sorted_signers.len() { + 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); + + // Ensure multisig doesn't already exist + ensure!( + !Multisigs::::contains_key(&multisig_address), + Error::::MultisigAlreadyExists + ); + + // Charge non-refundable fee (burned immediately) + let fee = T::MultisigFee::get(); + let _ = T::Currency::withdraw( + &creator, + fee, + frame_support::traits::WithdrawReasons::FEE, + frame_support::traits::ExistenceRequirement::KeepAlive, + ) + .map_err(|_| Error::::InsufficientBalance)?; + + // Reserve deposit from creator (will be returned after grace period) + let deposit = T::MultisigDeposit::get(); + T::Currency::reserve(&creator, deposit).map_err(|_| Error::::InsufficientBalance)?; + + // Convert sorted signers to bounded vec + 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, + deposit, + creator: creator.clone(), + last_activity: current_block, + active_proposals: 0, + }, + ); + + // Emit event with sorted signers + Self::deposit_event(Event::MultisigCreated { + creator, + multisig_address, + signers: bounded_signers.to_vec(), + threshold, + nonce, + }); + + Ok(()) + } + + /// Propose a transaction to be executed by the multisig + /// + /// Parameters: + /// - `multisig_address`: The multisig account that will execute the call + /// - `call`: The encoded call to execute + /// - `expiry`: Block number when this proposal expires + /// + /// The proposer must be a signer and must pay: + /// - A deposit (returned on execute or cancel) + /// - A fee (non-refundable, burned immediately) + #[pallet::call_index(1)] + #[pallet::weight(::WeightInfo::propose())] + pub fn propose( + origin: OriginFor, + multisig_address: T::AccountId, + call: Vec, + expiry: BlockNumberFor, + ) -> DispatchResult { + let proposer = ensure_signed(origin)?; + + // Check if proposer is a signer and active proposals limit + let multisig_data = + Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; + ensure!(multisig_data.signers.contains(&proposer), Error::::NotASigner); + + // Check active proposals limit + ensure!( + multisig_data.active_proposals < T::MaxActiveProposals::get(), + Error::::TooManyActiveProposals + ); + + // Check call size + ensure!(call.len() as u32 <= T::MaxCallSize::get(), Error::::CallTooLarge); + + // Charge non-refundable fee (burned immediately) + let fee = T::ProposalFee::get(); + let _ = T::Currency::withdraw( + &proposer, + fee, + frame_support::traits::WithdrawReasons::FEE, + frame_support::traits::ExistenceRequirement::KeepAlive, + ) + .map_err(|_| Error::::InsufficientBalance)?; + + // Reserve deposit from proposer (will be returned) + let deposit = T::ProposalDeposit::get(); + 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 = frame_system::Pallet::::block_number(); + } + }); + + // Convert to bounded vec + let bounded_call: BoundedCallOf = + call.try_into().map_err(|_| Error::::CallTooLarge)?; + + // Calculate proposal hash + let proposal_hash = T::Hashing::hash_of(&bounded_call); + + // Check if proposal already exists + ensure!( + !Proposals::::contains_key(&multisig_address, proposal_hash), + Error::::ProposalHasDeposit + ); + + // Create proposal with proposer as first approval + let mut approvals = BoundedApprovalsOf::::default(); + let _ = approvals.try_push(proposer.clone()); + + let proposal = ProposalData { + proposer: proposer.clone(), + call: bounded_call, + expiry, + approvals, + deposit, + }; + + // Store proposal + Proposals::::insert(&multisig_address, proposal_hash, proposal); + + // Increment active proposals counter + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.active_proposals = multisig.active_proposals.saturating_add(1); + } + }); + + // Emit event + Self::deposit_event(Event::TransactionProposed { + multisig_address, + proposer, + proposal_hash, + }); + + Ok(()) + } + + /// Approve a proposed transaction + /// + /// Parameters: + /// - `multisig_address`: The multisig account + /// - `proposal_hash`: Hash of the proposal to approve + #[pallet::call_index(2)] + #[pallet::weight(::WeightInfo::approve())] + pub fn approve( + origin: OriginFor, + multisig_address: T::AccountId, + proposal_hash: T::Hash, + ) -> DispatchResult { + let approver = ensure_signed(origin)?; + + // Check if approver is a signer + let multisig_data = + Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; + ensure!(multisig_data.signers.contains(&approver), Error::::NotASigner); + + // Get proposal + let mut proposal = Proposals::::get(&multisig_address, proposal_hash) + .ok_or(Error::::ProposalNotFound)?; + + // Check if not expired + let current_block = frame_system::Pallet::::block_number(); + ensure!(current_block <= proposal.expiry, Error::::ProposalExpired); + + // Check if already approved + ensure!(!proposal.approvals.contains(&approver), Error::::AlreadyApproved); + + // Add approval + proposal + .approvals + .try_push(approver.clone()) + .map_err(|_| Error::::TooManySigners)?; + + let approvals_count = proposal.approvals.len() as u32; + + // Update proposal + Proposals::::insert(&multisig_address, proposal_hash, 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(); + } + }); + + // Emit event + Self::deposit_event(Event::TransactionApproved { + multisig_address, + approver, + proposal_hash, + approvals_count, + }); + + Ok(()) + } + + /// Execute a transaction once threshold is met + /// + /// Parameters: + /// - `multisig_address`: The multisig account + /// - `proposal_hash`: Hash of the proposal to execute + #[pallet::call_index(3)] + #[pallet::weight(::WeightInfo::execute())] + pub fn execute( + origin: OriginFor, + multisig_address: T::AccountId, + proposal_hash: T::Hash, + ) -> DispatchResult { + let _ = ensure_signed(origin)?; + + // Get multisig data + let multisig_data = + Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; + + // Get proposal + let proposal = Proposals::::get(&multisig_address, proposal_hash) + .ok_or(Error::::ProposalNotFound)?; + + // Check if threshold met + ensure!( + proposal.approvals.len() as u32 >= multisig_data.threshold, + Error::::NotEnoughApprovals + ); + + // Decode the call before modifying storage + let call = ::RuntimeCall::decode(&mut &proposal.call[..]) + .map_err(|_| Error::::InvalidCall)?; + + // Return deposit to proposer + T::Currency::unreserve(&proposal.proposer, proposal.deposit); + + // Remove proposal + Proposals::::remove(&multisig_address, proposal_hash); + + // Update multisig: decrement counter and last_activity + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.last_activity = frame_system::Pallet::::block_number(); + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); + } + }); + + // Execute the call as the multisig account + let result = + call.dispatch(frame_system::RawOrigin::Signed(multisig_address.clone()).into()); + + // Emit event with execution result + Self::deposit_event(Event::TransactionExecuted { + multisig_address, + proposal_hash, + result: result.map(|_| ()).map_err(|e| e.error), + }); + + Ok(()) + } + + /// Cancel a proposed transaction (only by proposer) + /// + /// Parameters: + /// - `multisig_address`: The multisig account + /// - `proposal_hash`: Hash of the proposal to cancel + #[pallet::call_index(4)] + #[pallet::weight(::WeightInfo::cancel())] + pub fn cancel( + origin: OriginFor, + multisig_address: T::AccountId, + proposal_hash: T::Hash, + ) -> DispatchResult { + let canceller = ensure_signed(origin)?; + + // Get proposal + let proposal = Proposals::::get(&multisig_address, proposal_hash) + .ok_or(Error::::ProposalNotFound)?; + + // Check if caller is the proposer + ensure!(canceller == proposal.proposer, Error::::NotProposer); + + // Return deposit to proposer + T::Currency::unreserve(&proposal.proposer, proposal.deposit); + + // Remove proposal + Proposals::::remove(&multisig_address, proposal_hash); + + // Decrement active proposals counter + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); + } + }); + + // Emit event + Self::deposit_event(Event::TransactionCancelled { + multisig_address, + proposer: canceller, + proposal_hash, + }); + + Ok(()) + } + + /// Remove an expired proposal and return deposit to proposer + /// + /// Can be called by anyone after the proposal has expired. + /// - Within grace period: only proposer can remove, deposit returned + /// - After grace period: anyone can remove, deposit returned to proposer + /// + /// This ensures storage cleanup while giving proposers time to act. + #[pallet::call_index(5)] + #[pallet::weight(::WeightInfo::cancel())] + pub fn remove_expired( + origin: OriginFor, + multisig_address: T::AccountId, + proposal_hash: T::Hash, + ) -> DispatchResult { + let caller = ensure_signed(origin)?; + + // Get proposal + let proposal = Proposals::::get(&multisig_address, proposal_hash) + .ok_or(Error::::ProposalNotFound)?; + + // Check if expired + let current_block = frame_system::Pallet::::block_number(); + ensure!(current_block > proposal.expiry, Error::::ProposalNotExpired); + + // Calculate grace period end + let grace_period_end = proposal.expiry.saturating_add(T::GracePeriod::get()); + let is_in_grace = current_block <= grace_period_end; + let is_proposer = caller == proposal.proposer; + + // Within grace period: only proposer can remove + if is_in_grace { + ensure!(is_proposer, Error::::NotProposer); + } + // After grace period: anyone can remove + + // Return deposit to proposer + T::Currency::unreserve(&proposal.proposer, proposal.deposit); + + // Remove proposal from storage + Proposals::::remove(&multisig_address, proposal_hash); + + // Decrement active proposals counter + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); + } + }); + + // Emit event + Self::deposit_event(Event::ProposalRemoved { + multisig_address, + proposal_hash, + proposer: proposal.proposer.clone(), + removed_by: caller, + in_grace_period: is_in_grace, + }); + + Ok(()) + } + + /// Claim all deposits from cancelled and expired proposals, and inactive multisigs + /// + /// This is a batch operation that: + /// - Returns all proposal deposits where caller is proposer + /// - Returns multisig deposit if caller is creator and grace period elapsed + /// - Only works after grace period has elapsed + /// - Removes all cancelled and expired proposals from storage + /// - Removes multisig if inactive past grace period + /// - Single transaction to clean up all user's old deposits + /// + /// Use this after grace period to recover all your deposits at once. + #[pallet::call_index(6)] + #[pallet::weight(::WeightInfo::cancel().saturating_mul(10u64))] + pub fn claim_deposits( + origin: OriginFor, + multisig_address: T::AccountId, + ) -> DispatchResult { + let caller = ensure_signed(origin)?; + + let current_block = frame_system::Pallet::::block_number(); + let grace_period = T::GracePeriod::get(); + + let mut total_returned = BalanceOf::::zero(); + let mut removed_count = 0u32; + + // Iterate through all proposals for this multisig + let proposals_to_remove: Vec<(T::Hash, ProposalDataOf)> = + Proposals::::iter_prefix(&multisig_address) + .filter(|(_, proposal)| { + // Only proposals where caller is proposer + if proposal.proposer != caller { + return false; + } + + // Calculate grace period end + let grace_period_end = proposal.expiry.saturating_add(grace_period); + + // Only process if grace period has elapsed + current_block > grace_period_end + }) + .collect(); + + // Remove proposals and return deposits + for (hash, proposal) in proposals_to_remove { + // Return deposit + T::Currency::unreserve(&proposal.proposer, proposal.deposit); + total_returned = total_returned.saturating_add(proposal.deposit); + + // Remove from storage + Proposals::::remove(&multisig_address, hash); + removed_count = removed_count.saturating_add(1); + + // Decrement active proposals counter + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); + } + }); + + // Emit event for each removed proposal + Self::deposit_event(Event::ProposalRemoved { + multisig_address: multisig_address.clone(), + proposal_hash: hash, + proposer: caller.clone(), + removed_by: caller.clone(), + in_grace_period: false, + }); + } + + // Check if multisig itself can be removed + let mut multisig_removed = false; + if let Some(multisig_data) = Multisigs::::get(&multisig_address) { + // Calculate grace period end for multisig + let grace_period_end = multisig_data.last_activity.saturating_add(grace_period); + + // Check if grace period elapsed and no more proposals + let has_proposals = Proposals::::iter_prefix(&multisig_address).next().is_some(); + + if current_block > grace_period_end && !has_proposals { + // Check if caller is creator + if caller == multisig_data.creator { + // Return multisig deposit to creator + T::Currency::unreserve(&multisig_data.creator, multisig_data.deposit); + total_returned = total_returned.saturating_add(multisig_data.deposit); + + // Remove multisig from storage + Multisigs::::remove(&multisig_address); + + multisig_removed = true; + } + } + } + + // Emit summary event + Self::deposit_event(Event::DepositsClaimed { + multisig_address: multisig_address.clone(), + claimer: caller, + total_returned, + proposals_removed: removed_count, + multisig_removed, + }); + + 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 + 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(&nonce.encode()); + + // Hash the data and decode as AccountId + let hash = T::Hashing::hash(&data); + T::AccountId::decode(&mut &hash.as_ref()[..]) + .unwrap_or_else(|_| pallet_id.into_account_truncating()) + } + + /// Check if an account is a signer for a given multisig + pub fn is_signer(multisig_address: &T::AccountId, account: &T::AccountId) -> bool { + if let Some(multisig_data) = Multisigs::::get(multisig_address) { + multisig_data.signers.contains(account) + } else { + false + } + } + } +} diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs new file mode 100644 index 00000000..f611dc41 --- /dev/null +++ b/pallets/multisig/src/mock.rs @@ -0,0 +1,132 @@ +//! Mock runtime for testing pallet-multisig + +use crate as pallet_multisig; +use frame_support::{ + parameter_types, + traits::{ConstU32, Everything}, + PalletId, +}; +use sp_core::H256; +use sp_runtime::{ + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, +}; + +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, + } +); + +parameter_types! { + pub const BlockHashCount: u64 = 250; +} + +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 = u64; + type Lookup = IdentityLookup; + type BlockHashCount = BlockHashCount; + type Version = (); + type PalletInfo = PalletInfo; + 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; +} + +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 RuntimeHoldReason = RuntimeHoldReason; + type RuntimeFreezeReason = RuntimeFreezeReason; + type FreezeIdentifier = (); + type MaxFreezes = MaxFreezes; + type DoneSlashHandler = (); +} + +parameter_types! { + pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); + pub const MaxSignersParam: u32 = 10; + pub const MaxActiveProposalsParam: u32 = 10; // For testing + pub const MaxCallSizeParam: u32 = 1024; + pub const MultisigDepositParam: Balance = 100; + pub const MultisigFeeParam: Balance = 50; // Non-refundable fee + pub const ProposalDepositParam: Balance = 10; + pub const ProposalFeeParam: Balance = 5; // Non-refundable fee + pub const GracePeriodParam: u64 = 100; // 100 blocks for testing +} + +impl pallet_multisig::Config for Test { + type RuntimeCall = RuntimeCall; + type Currency = Balances; + type MaxSigners = MaxSignersParam; + type MaxActiveProposals = MaxActiveProposalsParam; + type MaxCallSize = MaxCallSizeParam; + type MultisigDeposit = MultisigDepositParam; + type MultisigFee = MultisigFeeParam; + type ProposalDeposit = ProposalDepositParam; + type ProposalFee = ProposalFeeParam; + type GracePeriod = GracePeriodParam; + type PalletId = MultisigPalletId; + 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![ + (1, 1000), // Alice + (2, 2000), // Bob + (3, 3000), // Charlie + (4, 4000), // Dave + (5, 5000), // Eve + ], + } + .assimilate_storage(&mut t) + .unwrap(); + + t.into() +} diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs new file mode 100644 index 00000000..5871ed07 --- /dev/null +++ b/pallets/multisig/src/tests.rs @@ -0,0 +1,1233 @@ +//! Unit tests for pallet-multisig + +use crate::{mock::*, Error, Event, GlobalNonce, Multisigs}; +use codec::Encode; +use frame_support::{assert_noop, assert_ok}; +use sp_runtime::traits::Hash; + +/// Helper function to get Alice's account ID +fn alice() -> u64 { + 1 +} + +/// Helper function to get Bob's account ID +fn bob() -> u64 { + 2 +} + +/// Helper function to get Charlie's account ID +fn charlie() -> u64 { + 3 +} + +/// Helper function to get Dave's account ID +fn dave() -> u64 { + 4 +} + +/// Helper function to create a simple encoded call +fn make_call(remark: Vec) -> Vec { + let call = RuntimeCall::System(frame_system::Call::remark { remark }); + call.encode() +} + +#[test] +fn create_multisig_works() { + new_test_ext().execute_with(|| { + // Initialize block number for events + System::set_block_number(1); + + // Setup + let creator = alice(); + let signers = vec![bob(), charlie(), dave()]; + let threshold = 2; + + // Get initial balance + let initial_balance = Balances::free_balance(creator); + let deposit = 100; // MultisigDepositParam + let fee = 50; // MultisigFeeParam + + // Create multisig + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator), + signers.clone(), + threshold, + )); + + // Check that deposit was reserved and fee was burned + assert_eq!(Balances::reserved_balance(creator), deposit); + assert_eq!(Balances::free_balance(creator), initial_balance - deposit - fee); + + // 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); + + // 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.deposit, deposit); + assert_eq!(multisig_data.signers.to_vec(), signers); + assert_eq!(multisig_data.active_proposals, 0); + + // Check that event was emitted + System::assert_last_event( + Event::MultisigCreated { creator, multisig_address, signers, threshold, nonce: 0 } + .into(), + ); + }); +} + +#[test] +fn create_multisig_fails_with_threshold_zero() { + new_test_ext().execute_with(|| { + let creator = alice(); + let signers = vec![bob(), charlie()]; + let threshold = 0; + + assert_noop!( + Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), + Error::::ThresholdZero + ); + }); +} + +#[test] +fn create_multisig_fails_with_empty_signers() { + new_test_ext().execute_with(|| { + let creator = alice(); + let signers = vec![]; + let threshold = 1; + + assert_noop!( + Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), + Error::::NotEnoughSigners + ); + }); +} + +#[test] +fn create_multisig_fails_with_threshold_too_high() { + new_test_ext().execute_with(|| { + let creator = alice(); + let signers = vec![bob(), charlie()]; + let threshold = 3; // More than the number of signers + + assert_noop!( + Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), + Error::::ThresholdTooHigh + ); + }); +} + +#[test] +fn create_multisig_fails_with_duplicate_signers() { + new_test_ext().execute_with(|| { + let creator = alice(); + let signers = vec![bob(), charlie(), bob()]; // Bob appears twice + let threshold = 2; + + assert_noop!( + Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), + Error::::DuplicateSigner + ); + }); +} + +#[test] +fn create_multisig_fails_with_too_many_signers() { + new_test_ext().execute_with(|| { + let creator = alice(); + // MaxSignersParam is 10, so 11 should fail + let signers: Vec = (1..=11).collect(); + let threshold = 2; + + assert_noop!( + Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), + Error::::TooManySigners + ); + }); +} + +#[test] +fn create_multisig_fails_with_insufficient_balance() { + new_test_ext().execute_with(|| { + // Create account with insufficient balance + let poor_account = 99; + let signers = vec![bob(), charlie()]; + let threshold = 2; + + // This account has 0 balance, can't pay deposit + assert_noop!( + Multisig::create_multisig(RuntimeOrigin::signed(poor_account), signers, threshold,), + Error::::InsufficientBalance + ); + }); +} + +#[test] +fn create_multiple_multisigs_works() { + new_test_ext().execute_with(|| { + // Initialize block number for events + System::set_block_number(1); + + let creator = alice(); + + // Create first multisig + let signers1 = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers1.clone(), 2,)); + + // Create second multisig with different signers + let signers2 = vec![charlie(), dave()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers2.clone(), 2,)); + + // Check global nonce incremented + assert_eq!(GlobalNonce::::get(), 2); + + // Check both multisigs exist + let multisig1 = Multisig::derive_multisig_address(&signers1, 0); + let multisig2 = Multisig::derive_multisig_address(&signers2, 1); + + assert!(Multisigs::::contains_key(multisig1)); + assert!(Multisigs::::contains_key(multisig2)); + + // Charlie can be in unlimited multisigs (no artificial limit) + // Both multisigs should exist independently + }); +} + +#[test] +fn max_active_proposals_limit_works() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // MaxActiveProposalsParam = 10 in mock + // Create 10 proposals (should work) + for i in 0..10 { + let call = make_call(vec![i as u8, 2, 3]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call, + 1000 + )); + } + + // Check counter + let multisig_data = Multisigs::::get(multisig_address).unwrap(); + assert_eq!(multisig_data.active_proposals, 10); + + // Try to create 11th proposal (should fail) + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, vec![99, 2, 3], 1000), + Error::::TooManyActiveProposals + ); + + // Execute one proposal to free up space + use frame_support::BoundedVec; + let call1 = make_call(vec![0, 2, 3]); + let bounded: BoundedVec::MaxCallSize> = + call1.try_into().unwrap(); + let hash1 = ::Hashing::hash_of(&bounded); + + assert_ok!(Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, hash1)); + assert_ok!(Multisig::execute(RuntimeOrigin::signed(alice()), multisig_address, hash1)); + + // Check counter decreased + let multisig_data = Multisigs::::get(multisig_address).unwrap(); + assert_eq!(multisig_data.active_proposals, 9); + + // Now we can create a new proposal + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + vec![100, 2, 3], + 1000 + )); + + // Counter back to 10 + let multisig_data = Multisigs::::get(multisig_address).unwrap(); + assert_eq!(multisig_data.active_proposals, 10); + }); +} + +#[test] +fn create_multisig_with_single_signer_works() { + new_test_ext().execute_with(|| { + let creator = alice(); + let signers = vec![bob()]; + let threshold = 1; + + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator), + signers.clone(), + threshold, + )); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + let multisig_data = Multisigs::::get(multisig_address).unwrap(); + + assert_eq!(multisig_data.threshold, 1); + assert_eq!(multisig_data.signers.len(), 1); + }); +} + +#[test] +fn is_signer_works() { + new_test_ext().execute_with(|| { + let creator = alice(); + let signers = vec![bob(), charlie(), dave()]; + let threshold = 2; + + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator), + signers.clone(), + threshold, + )); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Check signers + assert!(Multisig::is_signer(&multisig_address, &bob())); + assert!(Multisig::is_signer(&multisig_address, &charlie())); + assert!(Multisig::is_signer(&multisig_address, &dave())); + + // Check non-signers + assert!(!Multisig::is_signer(&multisig_address, &alice())); + assert!(!Multisig::is_signer(&multisig_address, &99)); + }); +} + +#[test] +fn derive_multisig_address_is_deterministic() { + new_test_ext().execute_with(|| { + let signers = vec![bob(), charlie(), dave()]; + let nonce = 42; + + let address1 = Multisig::derive_multisig_address(&signers, nonce); + let address2 = Multisig::derive_multisig_address(&signers, nonce); + + assert_eq!(address1, address2); + }); +} + +#[test] +fn derive_multisig_address_different_for_different_nonce() { + new_test_ext().execute_with(|| { + let signers = vec![bob(), charlie(), dave()]; + + let address1 = Multisig::derive_multisig_address(&signers, 0); + let address2 = Multisig::derive_multisig_address(&signers, 1); + + assert_ne!(address1, address2); + }); +} + +#[test] +fn derive_multisig_address_different_for_different_signers() { + new_test_ext().execute_with(|| { + let signers1 = vec![bob(), charlie()]; + let signers2 = vec![bob(), dave()]; + let nonce = 0; + + let address1 = Multisig::derive_multisig_address(&signers1, nonce); + let address2 = Multisig::derive_multisig_address(&signers2, nonce); + + assert_ne!(address1, address2); + }); +} + +#[test] +fn signer_order_does_not_matter_for_address() { + new_test_ext().execute_with(|| { + // Signers are sorted internally, so order doesn't matter + let signers1 = vec![bob(), charlie()]; + let signers2 = vec![charlie(), bob()]; + + // Sort both to simulate what happens in create_multisig + let mut sorted1 = signers1.clone(); + let mut sorted2 = signers2.clone(); + sorted1.sort(); + sorted2.sort(); + + let nonce = 0; + let address1 = Multisig::derive_multisig_address(&sorted1, nonce); + let address2 = Multisig::derive_multisig_address(&sorted2, nonce); + + // Same signers, same nonce = same address (order doesn't matter) + assert_eq!(address1, address2); + }); +} + +// ==================== PROPOSAL TESTS ==================== + +#[test] +fn propose_works() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + // Create multisig + let creator = alice(); + let signers = vec![bob(), charlie(), dave()]; + let threshold = 2; + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator), + signers.clone(), + threshold + )); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Propose a transaction + let proposer = bob(); + let call = vec![1, 2, 3, 4]; + let expiry = 1000; + let initial_balance = Balances::free_balance(proposer); + let proposal_deposit = 10; // ProposalDepositParam (refundable) + let proposal_fee = 5; // ProposalFeeParam (non-refundable) + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call.clone(), + expiry + )); + + // Check: fee was withdrawn (lost forever) + deposit was reserved + assert_eq!(Balances::reserved_balance(proposer), proposal_deposit); + assert_eq!( + Balances::free_balance(proposer), + initial_balance - proposal_deposit - proposal_fee + ); + + // Check proposal exists + let proposal_hash = ::Hashing::hash_of(&call); + assert!(crate::Proposals::::contains_key(multisig_address, proposal_hash)); + }); +} + +#[test] +fn propose_fails_if_not_signer() { + new_test_ext().execute_with(|| { + // Create multisig + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Try to propose as non-signer + let non_signer = dave(); + let call = make_call(vec![1, 2, 3]); + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(non_signer), multisig_address, call, 1000), + Error::::NotASigner + ); + }); +} + +#[test] +fn propose_fails_with_insufficient_balance() { + new_test_ext().execute_with(|| { + // Create multisig with poor account as signer + let creator = alice(); + let poor_account = 99; // No balance + let signers = vec![bob(), charlie(), poor_account]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Try to propose with insufficient balance + let call = make_call(vec![1, 2, 3]); + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(poor_account), multisig_address, call, 1000), + Error::::InsufficientBalance + ); + }); +} + +#[test] +fn approve_works() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + // Create multisig + let creator = alice(); + let signers = vec![bob(), charlie(), dave()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Propose + let call = make_call(vec![1, 2, 3, 4]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + 1000 + )); + + let proposal_hash = ::Hashing::hash_of(&call); + + // Approve + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Check approval was added + let proposal = crate::Proposals::::get(multisig_address, proposal_hash).unwrap(); + assert_eq!(proposal.approvals.len(), 2); // bob + charlie + assert!(proposal.approvals.contains(&charlie())); + }); +} + +#[test] +fn approve_fails_if_already_approved() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + 1000 + )); + + let proposal_hash = ::Hashing::hash_of(&call); + + // Try to approve twice + assert_noop!( + Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address, proposal_hash), + Error::::AlreadyApproved + ); + }); +} + +#[test] +fn execute_works() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let proposer = bob(); + let call = make_call(vec![1, 2, 3]); + let initial_balance = Balances::free_balance(proposer); + let _proposal_deposit = 10; // Refundable + let proposal_fee = 5; // Non-refundable + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call.clone(), + 1000 + )); + + let proposal_hash = ::Hashing::hash_of(&call); + + // Approve to reach threshold + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Execute + assert_ok!(Multisig::execute( + RuntimeOrigin::signed(alice()), + multisig_address, + proposal_hash + )); + + // Check deposit was returned, but fee was NOT returned + assert_eq!(Balances::reserved_balance(proposer), 0); + assert_eq!(Balances::free_balance(proposer), initial_balance - proposal_fee); // Only fee lost + + // Check proposal was removed + assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); + }); +} + +#[test] +fn execute_fails_without_threshold() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie(), dave()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 3)); // Need 3 approvals + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + 1000 + )); + + let proposal_hash = ::Hashing::hash_of(&call); + + // Only 2 approvals (bob + charlie), need 3 + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Try to execute without threshold + assert_noop!( + Multisig::execute(RuntimeOrigin::signed(alice()), multisig_address, proposal_hash), + Error::::NotEnoughApprovals + ); + }); +} + +#[test] +fn cancel_works() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let proposer = bob(); + let call = make_call(vec![1, 2, 3]); + let initial_balance = Balances::free_balance(proposer); + let proposal_fee = 5; // Non-refundable, even on cancel! + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call.clone(), + 1000 + )); + + let proposal_hash = ::Hashing::hash_of(&call); + + // Cancel + assert_ok!(Multisig::cancel( + RuntimeOrigin::signed(proposer), + multisig_address, + proposal_hash + )); + + // Check deposit was returned, but fee was NOT returned (even on cancel!) + assert_eq!(Balances::reserved_balance(proposer), 0); + assert_eq!(Balances::free_balance(proposer), initial_balance - proposal_fee); // Fee still lost + + // Check proposal was removed + assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); + }); +} + +#[test] +fn cancel_fails_if_not_proposer() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + 1000 + )); + + let proposal_hash = ::Hashing::hash_of(&call); + + // Try to cancel as different user + assert_noop!( + Multisig::cancel(RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash), + Error::::NotProposer + ); + }); +} + +#[test] +fn proposal_fee_is_never_returned() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let proposer = bob(); + let initial_balance = Balances::free_balance(proposer); + let proposal_deposit = 10; // Refundable + let proposal_fee = 5; // Non-refundable + + // Helper to get proposal hash (must convert to BoundedVec first) + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + + // Create 3 proposals + let call1 = make_call(vec![0, 2, 3]); + let call2 = make_call(vec![1, 2, 3]); + let call3 = make_call(vec![2, 2, 3]); + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call1.clone(), + 1000 + )); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call2.clone(), + 1000 + )); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call3.clone(), + 1000 + )); + + // After 3 proposals: 3 deposits reserved + 3 fees lost + assert_eq!(Balances::reserved_balance(proposer), 3 * proposal_deposit); + assert_eq!( + Balances::free_balance(proposer), + initial_balance - 3 * proposal_deposit - 3 * proposal_fee + ); + + // Cancel one proposal + let hash1 = get_hash(call1); + assert_ok!(Multisig::cancel(RuntimeOrigin::signed(proposer), multisig_address, hash1)); + + // After cancel: 2 deposits reserved + 3 fees still lost + assert_eq!(Balances::reserved_balance(proposer), 2 * proposal_deposit); + assert_eq!( + Balances::free_balance(proposer), + initial_balance - 2 * proposal_deposit - 3 * proposal_fee + ); + + // Execute another proposal + let hash2 = get_hash(call2); + assert_ok!(Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, hash2)); + assert_ok!(Multisig::execute(RuntimeOrigin::signed(alice()), multisig_address, hash2)); + + // After execute: 1 deposit reserved + 3 fees still lost + assert_eq!(Balances::reserved_balance(proposer), proposal_deposit); + assert_eq!( + Balances::free_balance(proposer), + initial_balance - proposal_deposit - 3 * proposal_fee + ); + + // Lesson: Fees are NEVER returned, regardless of outcome! + }); +} + +// ==================== EXPIRED PROPOSAL CLEANUP TESTS ==================== + +#[test] +fn remove_expired_fails_if_not_expired() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + let expiry = 1000; + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + expiry + )); + + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + let proposal_hash = get_hash(call); + + // Try to remove before expiry (at block 500) + System::set_block_number(500); + assert_noop!( + Multisig::remove_expired( + RuntimeOrigin::signed(alice()), + multisig_address, + proposal_hash + ), + Error::::ProposalNotExpired + ); + }); +} + +#[test] +fn remove_expired_within_grace_period_only_by_proposer() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let proposer = bob(); + let call = make_call(vec![1, 2, 3]); + let expiry = 1000; + let initial_balance = Balances::free_balance(proposer); + let _proposal_deposit = 10; + let proposal_fee = 5; + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call.clone(), + expiry + )); + + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + let proposal_hash = get_hash(call); + + // Move to grace period (expiry + 50 < expiry + grace_period(100)) + System::set_block_number(expiry + 50); + + // Non-proposer cannot remove within grace period + assert_noop!( + Multisig::remove_expired( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + ), + Error::::NotProposer + ); + + // Proposer CAN remove within grace period + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(proposer), + multisig_address, + proposal_hash + )); + + // Check deposit was returned, fee still lost + assert_eq!(Balances::reserved_balance(proposer), 0); + assert_eq!(Balances::free_balance(proposer), initial_balance - proposal_fee); + + // Check proposal was removed + assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); + }); +} + +#[test] +fn remove_expired_after_grace_period_by_anyone() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let proposer = bob(); + let call = make_call(vec![1, 2, 3]); + let expiry = 1000; + let initial_balance = Balances::free_balance(proposer); + let _proposal_deposit = 10; + let proposal_fee = 5; + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call.clone(), + expiry + )); + + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + let proposal_hash = get_hash(call); + + // Move past grace period (expiry + grace_period(100) + 1) + let grace_period = 100; // GracePeriodParam + System::set_block_number(expiry + grace_period + 1); + + // Anyone can remove after grace period (dave is not even a signer) + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(dave()), + multisig_address, + proposal_hash + )); + + // Check deposit was returned to proposer (not dave!) + assert_eq!(Balances::reserved_balance(proposer), 0); + assert_eq!(Balances::free_balance(proposer), initial_balance - proposal_fee); + + // Check proposal was removed + assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); + }); +} + +#[test] +fn remove_expired_multiple_proposals_cleanup() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + + // Create 3 proposals with different expiries + let call1 = make_call(vec![1, 2, 3]); + let call2 = make_call(vec![4, 5, 6]); + let call3 = make_call(vec![7, 8, 9]); + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call1.clone(), + 100 // expires at 100 + )); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call2.clone(), + 200 // expires at 200 + )); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call3.clone(), + 300 // expires at 300 + )); + + let hash1 = get_hash(call1); + let hash2 = get_hash(call2); + let hash3 = get_hash(call3); + + // Move past all expiries + grace period + System::set_block_number(500); + + // Cleanup all 3 (anyone can do it) + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(dave()), + multisig_address, + hash1 + )); + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(dave()), + multisig_address, + hash2 + )); + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(dave()), + multisig_address, + hash3 + )); + + // All removed + assert!(!crate::Proposals::::contains_key(multisig_address, hash1)); + assert!(!crate::Proposals::::contains_key(multisig_address, hash2)); + assert!(!crate::Proposals::::contains_key(multisig_address, hash3)); + + // Bob got all 3 deposits back (3 × 10 = 30) + let _proposal_deposit = 10; + let _proposal_fee = 5; + assert_eq!(Balances::reserved_balance(bob()), 0); + // Initial 1000 - (3 deposits still reserved before cleanup) - (3 fees lost) = back to + // initial - 3 fees After cleanup: initial - 3 fees + }); +} + +// ==================== CLAIM DEPOSITS TESTS ==================== + +#[test] +fn claim_deposits_removes_expired_proposals() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let proposer = bob(); + let initial_balance = Balances::free_balance(proposer); + let proposal_deposit = 10; + let proposal_fee = 5; + + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + + // Create 3 proposals with same expiry + let call1 = make_call(vec![1, 2, 3]); + let call2 = make_call(vec![4, 5, 6]); + let call3 = make_call(vec![7, 8, 9]); + let expiry = 100; + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call1.clone(), + expiry + )); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call2.clone(), + expiry + )); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call3.clone(), + expiry + )); + + // After proposals: 3 deposits reserved, 3 fees lost + assert_eq!(Balances::reserved_balance(proposer), 3 * proposal_deposit); + assert_eq!( + Balances::free_balance(proposer), + initial_balance - 3 * proposal_deposit - 3 * proposal_fee + ); + + // Move past expiry + grace period + let grace_period = 100; + System::set_block_number(expiry + grace_period + 1); + + // Claim all deposits at once + assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(proposer), multisig_address)); + + // All deposits returned + assert_eq!(Balances::reserved_balance(proposer), 0); + assert_eq!( + Balances::free_balance(proposer), + initial_balance - 3 * proposal_fee // Only fees lost + ); + + // All proposals removed + assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call1))); + assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call2))); + assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call3))); + }); +} + +#[test] +fn claim_deposits_only_cleans_own_proposals() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let expiry = 100; + + // Bob creates 2 proposals + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + vec![1, 2, 3], + expiry + )); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + vec![4, 5, 6], + expiry + )); + + // Charlie creates 1 proposal + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(charlie()), + multisig_address, + vec![7, 8, 9], + expiry + )); + + // Move past grace period + let grace_period = 100; + System::set_block_number(expiry + grace_period + 1); + + let _bob_initial = Balances::free_balance(bob()); + let _charlie_initial = Balances::free_balance(charlie()); + + // Bob claims - should only get his 2 deposits back + assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); + + // Bob: 2 deposits returned + assert_eq!(Balances::reserved_balance(bob()), 0); + + // Charlie: still has 1 deposit reserved + assert_eq!(Balances::reserved_balance(charlie()), 10); + + // Charlie claims his + assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(charlie()), multisig_address)); + + // Charlie: deposit returned + assert_eq!(Balances::reserved_balance(charlie()), 0); + }); +} + +#[test] +fn claim_deposits_respects_grace_period() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let expiry = 100; + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + vec![1, 2, 3], + expiry + )); + + // Move to within grace period + System::set_block_number(expiry + 50); // grace is 100 + + // Claim during grace - should not remove anything + assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); + + // Deposit still reserved (nothing was cleaned) + assert_eq!(Balances::reserved_balance(bob()), 10); + + // Move past grace period + System::set_block_number(expiry + 101); + + // Claim after grace - should work + assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); + + // Deposit returned + assert_eq!(Balances::reserved_balance(bob()), 0); + }); +} + +#[test] +fn claim_deposits_works_for_mixed_proposals() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + + // Create proposals with different expiries + let call1 = make_call(vec![1, 2, 3]); + let call2 = make_call(vec![4, 5, 6]); + let call3 = make_call(vec![7, 8, 9]); + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call1.clone(), + 100 // expires early + )); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call2.clone(), + 500 // expires late + )); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call3.clone(), + 100 // expires early + )); + + // Move past first expiry + grace (100 + 100 = 200) + System::set_block_number(201); + + // Claim - should only clean expired proposals + assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); + + // 2 deposits returned (call1, call3 expired) + assert_eq!(Balances::reserved_balance(bob()), 10); // 1 still reserved + + // call2 still exists (not expired yet) + assert!(crate::Proposals::::contains_key(multisig_address, get_hash(call2))); + + // call1, call3 removed + assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call1))); + assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call3))); + }); +} diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs new file mode 100644 index 00000000..b30ea660 --- /dev/null +++ b/pallets/multisig/src/weights.rs @@ -0,0 +1,246 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +//! Autogenerated weights for `pallet_multisig` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 +//! DATE: 2026-01-16, 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` + +// Executed Command: +// ./target/release/quantus-node +// benchmark +// pallet +// --chain=dev +// --pallet=pallet_multisig +// --extrinsic=* +// --steps=50 +// --repeat=20 +// --template=./.maintain/frame-weight-template.hbs +// --output=./pallets/multisig/src/weights.rs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] +#![allow(dead_code)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for `pallet_multisig`. +pub trait WeightInfo { + fn create_multisig() -> Weight; + fn propose() -> Weight; + fn approve() -> Weight; + fn execute() -> Weight; + fn cancel() -> Weight; + fn remove_expired() -> Weight; + fn claim_deposits() -> Weight; +} + +/// 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(3318), added: 5793, mode: `MaxEncodedLen`) + fn create_multisig() -> Weight { + // Proof Size summary in bytes: + // Measured: `152` + // Estimated: `6783` + // Minimum execution time: 177_000_000 picoseconds. + Weight::from_parts(190_000_000, 6783) + .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + fn propose() -> Weight { + // Proof Size summary in bytes: + // Measured: `477` + // Estimated: `17057` + // Minimum execution time: 150_000_000 picoseconds. + Weight::from_parts(161_000_000, 17057) + .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + fn approve() -> Weight { + // Proof Size summary in bytes: + // Measured: `790` + // Estimated: `17057` + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(14_000_000, 17057) + .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + fn execute() -> Weight { + // Proof Size summary in bytes: + // Measured: `822` + // Estimated: `17057` + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(22_000_000, 17057) + .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(13592), added: 16067, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + fn cancel() -> Weight { + // Proof Size summary in bytes: + // Measured: `790` + // Estimated: `17057` + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(25_000_000, 17057) + .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(13592), added: 16067, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + fn remove_expired() -> Weight { + // Proof Size summary in bytes: + // Measured: `790` + // Estimated: `17057` + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(20_000_000, 17057) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: `Multisig::Proposals` (r:6 w:0) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:0) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + fn claim_deposits() -> Weight { + // Proof Size summary in bytes: + // Measured: `1954` + // Estimated: `97392` + // Minimum execution time: 26_000_000 picoseconds. + Weight::from_parts(30_000_000, 97392) + .saturating_add(T::DbWeight::get().reads(7_u64)) + } +} + +// 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(3318), added: 5793, mode: `MaxEncodedLen`) + fn create_multisig() -> Weight { + // Proof Size summary in bytes: + // Measured: `152` + // Estimated: `6783` + // Minimum execution time: 177_000_000 picoseconds. + Weight::from_parts(190_000_000, 6783) + .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + fn propose() -> Weight { + // Proof Size summary in bytes: + // Measured: `477` + // Estimated: `17057` + // Minimum execution time: 150_000_000 picoseconds. + Weight::from_parts(161_000_000, 17057) + .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + fn approve() -> Weight { + // Proof Size summary in bytes: + // Measured: `790` + // Estimated: `17057` + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(14_000_000, 17057) + .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + fn execute() -> Weight { + // Proof Size summary in bytes: + // Measured: `822` + // Estimated: `17057` + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(22_000_000, 17057) + .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(13592), added: 16067, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + fn cancel() -> Weight { + // Proof Size summary in bytes: + // Measured: `790` + // Estimated: `17057` + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(25_000_000, 17057) + .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(13592), added: 16067, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + fn remove_expired() -> Weight { + // Proof Size summary in bytes: + // Measured: `790` + // Estimated: `17057` + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(20_000_000, 17057) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: `Multisig::Proposals` (r:6 w:0) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:0) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + fn claim_deposits() -> Weight { + // Proof Size summary in bytes: + // Measured: `1954` + // Estimated: `97392` + // Minimum execution time: 26_000_000 picoseconds. + Weight::from_parts(30_000_000, 97392) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + } +} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 6f039fe1..fe292af7 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -32,6 +32,7 @@ pallet-assets-holder = { workspace = true, default-features = false } pallet-balances.workspace = true pallet-conviction-voting.workspace = true pallet-mining-rewards.workspace = true +pallet-multisig.workspace = true pallet-preimage.workspace = true pallet-qpow.workspace = true pallet-ranked-collective.workspace = true @@ -95,6 +96,7 @@ std = [ "pallet-balances/std", "pallet-conviction-voting/std", "pallet-mining-rewards/std", + "pallet-multisig/std", "pallet-preimage/std", "pallet-qpow/std", "pallet-ranked-collective/std", @@ -142,6 +144,7 @@ runtime-benchmarks = [ "pallet-balances/runtime-benchmarks", "pallet-conviction-voting/runtime-benchmarks", "pallet-mining-rewards/runtime-benchmarks", + "pallet-multisig/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", "pallet-qpow/runtime-benchmarks", "pallet-ranked-collective/runtime-benchmarks", diff --git a/runtime/src/benchmarks.rs b/runtime/src/benchmarks.rs index 6efb6e0c..cf13e3e1 100644 --- a/runtime/src/benchmarks.rs +++ b/runtime/src/benchmarks.rs @@ -31,6 +31,7 @@ frame_benchmarking::define_benchmarks!( [pallet_sudo, Sudo] [pallet_reversible_transfers, ReversibleTransfers] [pallet_mining_rewards, MiningRewards] + [pallet_multisig, Multisig] [pallet_scheduler, Scheduler] [pallet_qpow, QPoW] ); diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 28c996b4..31358ee5 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -567,6 +567,34 @@ impl pallet_assets_holder::Config for Runtime { type RuntimeHoldReason = RuntimeHoldReason; } +// Multisig configuration +parameter_types! { + pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); + pub const MaxSigners: u32 = 100; + pub const MaxActiveProposals: u32 = 100; // Max active proposals per multisig + pub const MaxCallSize: u32 = 10240; // 10KB + pub const MultisigDeposit: Balance = 100 * MILLI_UNIT; // 0.1 UNIT (refundable) + pub const MultisigFee: Balance = 100 * MILLI_UNIT; // 0.1 UNIT (non-refundable) + pub const ProposalDeposit: Balance = 1000 * MILLI_UNIT; // 1 UNIT (refundable) + pub const ProposalFee: Balance = 1000 * MILLI_UNIT; // 1 UNIT (non-refundable) + pub const ProposalGracePeriod: BlockNumber = 28800; // ~2 days (6s blocks) +} + +impl pallet_multisig::Config for Runtime { + type RuntimeCall = RuntimeCall; + type Currency = Balances; + type MaxSigners = MaxSigners; + type MaxActiveProposals = MaxActiveProposals; + type MaxCallSize = MaxCallSize; + type MultisigDeposit = MultisigDeposit; + type MultisigFee = MultisigFee; + type ProposalDeposit = ProposalDeposit; + type ProposalFee = ProposalFee; + type GracePeriod = ProposalGracePeriod; + type PalletId = MultisigPalletId; + type WeightInfo = pallet_multisig::weights::SubstrateWeight; +} + impl TryFrom for pallet_balances::Call { type Error = (); fn try_from(call: RuntimeCall) -> Result { diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 01b17135..d1ef9c8f 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -253,4 +253,7 @@ mod runtime { #[runtime::pallet_index(22)] pub type AssetsHolder = pallet_assets_holder; + + #[runtime::pallet_index(23)] + pub type Multisig = pallet_multisig; } From 2527a91f6b84d3b178b4957bfa959640e5952ffb Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Fri, 16 Jan 2026 12:04:32 +0800 Subject: [PATCH 04/27] fix: Taplo --- pallets/multisig/Cargo.toml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pallets/multisig/Cargo.toml b/pallets/multisig/Cargo.toml index a63c2b9c..1f31f398 100644 --- a/pallets/multisig/Cargo.toml +++ b/pallets/multisig/Cargo.toml @@ -20,9 +20,9 @@ log.workspace = true pallet-balances.workspace = true scale-info = { features = ["derive"], workspace = true } sp-arithmetic.workspace = true -sp-runtime.workspace = true -sp-io.workspace = true sp-core.workspace = true +sp-io.workspace = true +sp-runtime.workspace = true [dev-dependencies] frame-support = { workspace = true, features = ["experimental"], default-features = true } @@ -33,6 +33,13 @@ sp-io.workspace = true [features] default = ["std"] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", +] std = [ "codec/std", "frame-benchmarking?/std", @@ -42,17 +49,10 @@ std = [ "pallet-balances/std", "pallet-timestamp/std", "scale-info/std", + "sp-arithmetic/std", "sp-core/std", "sp-io/std", "sp-runtime/std", - "sp-arithmetic/std", -] -runtime-benchmarks = [ - "frame-benchmarking", - "frame-support/runtime-benchmarks", - "frame-system/runtime-benchmarks", - "pallet-balances/runtime-benchmarks", - "sp-runtime/runtime-benchmarks", ] try-runtime = [ "frame-support/try-runtime", From 899d86491fe9702bae4e5e0e0cc4186028bef7b4 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Fri, 16 Jan 2026 15:10:21 +0800 Subject: [PATCH 05/27] fix: Execution for expired & address simplified fallback --- pallets/multisig/README.md | 2 +- pallets/multisig/src/benchmarking.rs | 30 +++++-------- pallets/multisig/src/lib.rs | 52 +++++++++++++++++++---- pallets/multisig/src/tests.rs | 45 ++++++++++++++++++++ pallets/multisig/src/weights.rs | 52 +++++++++++------------ runtime/tests/common.rs | 4 +- runtime/tests/transactions/integration.rs | 4 +- 7 files changed, 129 insertions(+), 60 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index 938eb69a..bdc0c4c6 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -74,8 +74,8 @@ Executes a proposal once threshold is met. **Validation:** - Proposal must exist +- Proposal must not be expired (current_block ≤ expiry) - Number of approvals must be ≥ threshold -- No expiry check (can execute even after expiry if threshold was met before) **Economic Effects:** - ProposalDeposit returned to proposer diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 1b3ee940..5d85aa98 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -145,10 +145,8 @@ mod benchmarks { deposit: 10u32.into(), }; - let proposal_hash = ::Hashing::hash_of(&( - multisig_address.clone(), - encoded_call, - )); + // Match pallet hashing: hash_of(bounded_call) + let proposal_hash = ::Hashing::hash_of(&proposal_data.call); Proposals::::insert(&multisig_address, proposal_hash, proposal_data); #[extrinsic_call] @@ -211,10 +209,8 @@ mod benchmarks { deposit: 10u32.into(), }; - let proposal_hash = ::Hashing::hash_of(&( - multisig_address.clone(), - encoded_call, - )); + // Match pallet hashing: hash_of(bounded_call) + let proposal_hash = ::Hashing::hash_of(&proposal_data.call); Proposals::::insert(&multisig_address, proposal_hash, proposal_data); #[extrinsic_call] @@ -273,10 +269,8 @@ mod benchmarks { deposit: 10u32.into(), }; - let proposal_hash = ::Hashing::hash_of(&( - multisig_address.clone(), - encoded_call, - )); + // Match pallet hashing: hash_of(bounded_call) + let proposal_hash = ::Hashing::hash_of(&proposal_data.call); Proposals::::insert(&multisig_address, proposal_hash, proposal_data); #[extrinsic_call] @@ -335,10 +329,8 @@ mod benchmarks { deposit: 10u32.into(), }; - let proposal_hash = ::Hashing::hash_of(&( - multisig_address.clone(), - encoded_call, - )); + // Match pallet hashing: hash_of(bounded_call) + let proposal_hash = ::Hashing::hash_of(&proposal_data.call); Proposals::::insert(&multisig_address, proposal_hash, proposal_data); // Move past expiry + grace period @@ -403,10 +395,8 @@ mod benchmarks { deposit: 10u32.into(), }; - let proposal_hash = ::Hashing::hash_of(&( - multisig_address.clone(), - encoded_call, - )); + // Match pallet hashing: hash_of(bounded_call) + let proposal_hash = ::Hashing::hash_of(&proposal_data.call); Proposals::::insert(&multisig_address, proposal_hash, proposal_data); } diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 472cd221..85a31155 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -98,14 +98,15 @@ pub mod pallet { use super::*; use codec::Encode; use frame_support::{ - dispatch::{DispatchResult, GetDispatchInfo, PostDispatchInfo}, + dispatch::{DispatchClass, DispatchResult, GetDispatchInfo, Pays, PostDispatchInfo}, pallet_prelude::*, traits::{Currency, ReservableCurrency}, + weights::Weight, PalletId, }; use frame_system::pallet_prelude::*; use sp_arithmetic::traits::Saturating; - use sp_runtime::traits::{AccountIdConversion, Dispatchable, Hash}; + use sp_runtime::traits::{Dispatchable, Hash, TrailingZeroInput}; #[pallet::pallet] pub struct Pallet(_); @@ -580,7 +581,11 @@ pub mod pallet { /// - `multisig_address`: The multisig account /// - `proposal_hash`: Hash of the proposal to execute #[pallet::call_index(3)] - #[pallet::weight(::WeightInfo::execute())] + #[pallet::weight(( + Pallet::::execute_weight(multisig_address, proposal_hash), + DispatchClass::Normal, + Pays::Yes + ))] pub fn execute( origin: OriginFor, multisig_address: T::AccountId, @@ -596,6 +601,10 @@ pub mod pallet { let proposal = Proposals::::get(&multisig_address, proposal_hash) .ok_or(Error::::ProposalNotFound)?; + // Check if not expired + let current_block = frame_system::Pallet::::block_number(); + ensure!(current_block <= proposal.expiry, Error::::ProposalExpired); + // Check if threshold met ensure!( proposal.approvals.len() as u32 >= multisig_data.threshold, @@ -686,7 +695,7 @@ pub mod pallet { /// /// This ensures storage cleanup while giving proposers time to act. #[pallet::call_index(5)] - #[pallet::weight(::WeightInfo::cancel())] + #[pallet::weight(::WeightInfo::remove_expired())] pub fn remove_expired( origin: OriginFor, multisig_address: T::AccountId, @@ -750,7 +759,7 @@ pub mod pallet { /// /// Use this after grace period to recover all your deposits at once. #[pallet::call_index(6)] - #[pallet::weight(::WeightInfo::cancel().saturating_mul(10u64))] + #[pallet::weight(::WeightInfo::claim_deposits())] pub fn claim_deposits( origin: OriginFor, multisig_address: T::AccountId, @@ -845,19 +854,44 @@ pub mod pallet { } impl Pallet { + /// Weight for `execute` is base multisig cost + dispatched call weight. + /// + /// This prevents underpaying (DoS economics) by ensuring the extrinsic weight + /// covers the worst-case weight of the stored call. + pub fn execute_weight(multisig_address: &T::AccountId, proposal_hash: &T::Hash) -> Weight { + let base = ::WeightInfo::execute(); + + // Best-effort: if proposal/call decoding fails, fall back to base weight. + let Some(proposal) = Proposals::::get(multisig_address, proposal_hash) else { + return base; + }; + + let Ok(call) = ::RuntimeCall::decode(&mut &proposal.call[..]) else { + return base; + }; + + base.saturating_add(call.get_dispatch_info().call_weight) + } + /// 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 + // Create a unique identifier from pallet id + signers + nonce. + // + // IMPORTANT: + // - Do NOT `Decode` directly from a finite byte-slice and then "fallback" to a constant + // address on error: that can cause address collisions / DoS. + // - Using `TrailingZeroInput` makes decoding deterministic and infallible by providing + // an infinite stream (hash bytes padded with zeros). 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(&nonce.encode()); - // Hash the data and decode as AccountId + // Hash the data and map it deterministically into an AccountId. let hash = T::Hashing::hash(&data); - T::AccountId::decode(&mut &hash.as_ref()[..]) - .unwrap_or_else(|_| pallet_id.into_account_truncating()) + T::AccountId::decode(&mut TrailingZeroInput::new(hash.as_ref())) + .expect("TrailingZeroInput provides sufficient bytes; qed") } /// Check if an account is a signer for a given multisig diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 5871ed07..7659da10 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -570,6 +570,51 @@ fn execute_works() { }); } +#[test] +fn execute_fails_if_expired_even_if_threshold_met() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + let expiry = 10; + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + expiry + )); + + // Match pallet hashing: hash_of(bounded_call) + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + let proposal_hash = get_hash(call); + + // Reach threshold before expiry (bob auto-approves in propose) + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Move past expiry and attempt to execute + System::set_block_number(expiry + 1); + assert_noop!( + Multisig::execute(RuntimeOrigin::signed(alice()), multisig_address, proposal_hash), + Error::::ProposalExpired + ); + }); +} + #[test] fn execute_fails_without_threshold() { new_test_ext().execute_with(|| { diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index b30ea660..fc8b2307 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -67,7 +67,7 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `6783` - // Minimum execution time: 177_000_000 picoseconds. + // Minimum execution time: 189_000_000 picoseconds. Weight::from_parts(190_000_000, 6783) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -80,8 +80,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `477` // Estimated: `17057` - // Minimum execution time: 150_000_000 picoseconds. - Weight::from_parts(161_000_000, 17057) + // Minimum execution time: 151_000_000 picoseconds. + Weight::from_parts(162_000_000, 17057) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -93,8 +93,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `790` // Estimated: `17057` - // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(14_000_000, 17057) + // Minimum execution time: 14_000_000 picoseconds. + Weight::from_parts(15_000_000, 17057) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -106,8 +106,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `822` // Estimated: `17057` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17057) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(23_000_000, 17057) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -119,8 +119,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `790` // Estimated: `17057` - // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(25_000_000, 17057) + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(20_000_000, 17057) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -132,7 +132,7 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `790` // Estimated: `17057` - // Minimum execution time: 18_000_000 picoseconds. + // Minimum execution time: 20_000_000 picoseconds. Weight::from_parts(20_000_000, 17057) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -143,10 +143,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1954` + // Measured: `1898` // Estimated: `97392` - // Minimum execution time: 26_000_000 picoseconds. - Weight::from_parts(30_000_000, 97392) + // Minimum execution time: 27_000_000 picoseconds. + Weight::from_parts(28_000_000, 97392) .saturating_add(T::DbWeight::get().reads(7_u64)) } } @@ -161,7 +161,7 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `6783` - // Minimum execution time: 177_000_000 picoseconds. + // Minimum execution time: 189_000_000 picoseconds. Weight::from_parts(190_000_000, 6783) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -174,8 +174,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `477` // Estimated: `17057` - // Minimum execution time: 150_000_000 picoseconds. - Weight::from_parts(161_000_000, 17057) + // Minimum execution time: 151_000_000 picoseconds. + Weight::from_parts(162_000_000, 17057) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -187,8 +187,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `790` // Estimated: `17057` - // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(14_000_000, 17057) + // Minimum execution time: 14_000_000 picoseconds. + Weight::from_parts(15_000_000, 17057) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -200,8 +200,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `822` // Estimated: `17057` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17057) + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(23_000_000, 17057) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -213,8 +213,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `790` // Estimated: `17057` - // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(25_000_000, 17057) + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(20_000_000, 17057) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -226,7 +226,7 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `790` // Estimated: `17057` - // Minimum execution time: 18_000_000 picoseconds. + // Minimum execution time: 20_000_000 picoseconds. Weight::from_parts(20_000_000, 17057) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -237,10 +237,10 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1954` + // Measured: `1898` // Estimated: `97392` - // Minimum execution time: 26_000_000 picoseconds. - Weight::from_parts(30_000_000, 97392) + // Minimum execution time: 27_000_000 picoseconds. + Weight::from_parts(28_000_000, 97392) .saturating_add(RocksDbWeight::get().reads(7_u64)) } } diff --git a/runtime/tests/common.rs b/runtime/tests/common.rs index d4eed8c8..452351ae 100644 --- a/runtime/tests/common.rs +++ b/runtime/tests/common.rs @@ -38,8 +38,8 @@ impl TestCommons { /// Create a test externality with governance track timing based on feature flags /// - Without `production-governance-tests`: Uses fast 2-block periods for all governance tracks - /// - With `production-governance-tests`: Uses production timing (hours/days) - /// This allows CI to test both fast (for speed) and slow (for correctness) governance + /// - With `production-governance-tests`: Uses production timing (hours/days) This allows CI to + /// test both fast (for speed) and slow (for correctness) governance pub fn new_fast_governance_test_ext() -> sp_io::TestExternalities { #[cfg(feature = "production-governance-tests")] { diff --git a/runtime/tests/transactions/integration.rs b/runtime/tests/transactions/integration.rs index 4ae80b98..55ea57bc 100644 --- a/runtime/tests/transactions/integration.rs +++ b/runtime/tests/transactions/integration.rs @@ -106,11 +106,11 @@ mod tests { // Extract components into individual variables for debugging let decoded_address: Address = address; let decoded_signature: DilithiumSignatureScheme = signature; - let decoded_extra: SignedExtra = extra; + let _: SignedExtra = extra; // Debug output for each component println!("Decoded Address: {:?}", decoded_address); - println!("Decoded Extra: {:?}", decoded_extra); + println!("Decoded Extra: ()"); let DilithiumSignatureScheme::Dilithium(sig_public) = decoded_signature.clone(); let sig = sig_public.signature(); From ad670eb4aaf1b027a26b6161353e7e5831c936bd Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Mon, 19 Jan 2026 10:23:13 +0800 Subject: [PATCH 06/27] draft: Historical proposals - paginaged endpoint --- pallets/multisig/README.md | 158 ++++++++++-- pallets/multisig/src/benchmarking.rs | 28 ++- pallets/multisig/src/lib.rs | 300 +++++++++++++++-------- pallets/multisig/src/mock.rs | 2 + pallets/multisig/src/tests.rs | 349 ++++++++++++++++++++++++--- pallets/multisig/src/weights.rs | 6 +- runtime/src/configs/mod.rs | 2 + 7 files changed, 667 insertions(+), 178 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index bdc0c4c6..dcc5c0cb 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -6,6 +6,27 @@ A multisignature wallet pallet for the Quantus blockchain with an economic secur This pallet provides functionality for creating and managing multisig accounts that require multiple approvals before executing transactions. It implements a dual fee+deposit system for spam prevention and storage cleanup mechanisms with grace periods. +## Quick Start + +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); + +// 2. Bob proposes a transaction +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) +Multisig::approve(Origin::signed(charlie), multisig_addr, proposal_hash); +// ✅ Transaction executed! No separate call needed. +``` + +**Key Point:** Once the threshold is reached, the transaction is **automatically executed**. +There is no separate `execute()` call exposed to users. + ## Core Functionality ### 1. Create Multisig @@ -51,7 +72,8 @@ Creates a new proposal for multisig execution. **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. +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.** **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) @@ -63,28 +85,16 @@ Adds caller's approval to an existing proposal. - Proposal must not be expired (current_block ≤ expiry) - Caller must not have already approved -**Economic Costs:** None (only transaction fees) - -### 4. Execute Transaction -Executes a proposal once threshold is met. - -**Required Parameters:** -- `multisig_address: AccountId` - Target multisig (REQUIRED) -- `proposal_hash: Hash` - Hash of proposal to execute (REQUIRED) - -**Validation:** -- Proposal must exist -- Proposal must not be expired (current_block ≤ expiry) -- Number of approvals must be ≥ threshold - -**Economic Effects:** +**Auto-Execution:** +When approval count reaches the threshold: +- Encoded call is executed as multisig_address origin - ProposalDeposit returned to proposer - Proposal removed from storage -- Encoded call executed as multisig_address origin +- TransactionExecuted event emitted with execution result -**Economic Costs:** None (deposit returned) +**Economic Costs:** None (only transaction fees, deposit returned on execution) -### 5. Cancel Transaction +### 4. Cancel Transaction Cancels a proposal (proposer only). **Required Parameters:** @@ -103,7 +113,7 @@ Cancels a proposal (proposer only). **Note:** ProposalFee is NOT refunded - it was burned at proposal creation. -### 6. Remove Expired +### 5. Remove Expired Removes expired proposals from storage (cleanup mechanism). **Required Parameters:** @@ -122,7 +132,7 @@ Removes expired proposals from storage (cleanup mechanism). **Economic Costs:** None (deposit always returned to proposer) -### 7. Claim Deposits +### 6. Claim Deposits Batch cleanup operation to recover all eligible deposits. **Required Parameters:** @@ -191,6 +201,23 @@ ProposalData { } ``` +### ExecutedProposals: DoubleMap +**Archive of successfully executed proposals.** Only proposals that were executed are stored here. +Cancelled or expired proposals are NOT archived (only available in events). + +```rust +ExecutedProposalData { + proposer: AccountId, // Who proposed + call: BoundedVec, // The call that was executed + approvers: BoundedVec, // Full list of who approved + executed_at: BlockNumber, // When it was executed + execution_succeeded: bool, // Whether the call succeeded +} +``` + +**Purpose:** Provides permanent on-chain history of all executed multisig transactions. +Can be queried using `Multisig::get_executed_proposal(multisig_address, proposal_hash)`. + ### GlobalNonce: u64 Internal counter for generating unique multisig addresses. Not exposed via API. @@ -217,10 +244,10 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `ProposalNotFound` - Proposal does not exist - `NotProposer` - Caller is not the proposer (for cancel) - `AlreadyApproved` - Signer already approved this proposal -- `NotEnoughApprovals` - Threshold not met (for execute) +- `NotEnoughApprovals` - Threshold not met (internal error, should not occur) - `ProposalExpired` - Proposal deadline passed (for approve) - `CallTooLarge` - Encoded call exceeds MaxCallSize -- `InvalidCall` - Call decoding failed (for execute) +- `InvalidCall` - Call decoding failed during execution - `InsufficientBalance` - Not enough funds for fee/deposit - `TooManyActiveProposals` - Multisig has MaxActiveProposals open proposals - `ProposalNotExpired` - Proposal not yet expired (for remove_expired) @@ -245,6 +272,90 @@ create_multisig([charlie, bob, alice], 2) // → multisig_addr_1 (SAME! nonce wo create_multisig([alice, bob, charlie], 2) // → multisig_addr_2 (nonce=1, different address) ``` +## Querying Executed Proposals + +The pallet maintains a permanent archive of successfully executed proposals in the `ExecutedProposals` storage. This archive includes: +- Proposer account +- Encoded call that was executed +- List of approvers +- Execution timestamp (block number) +- Execution result (success/failure) + +### Query Methods + +#### 1. Get Single Proposal +```rust +// Query by multisig address and proposal hash +let proposal = Multisig::get_executed_proposal(&multisig_address, &proposal_hash); +if let Some(data) = proposal { + println!("Proposer: {:?}", data.proposer); + println!("Executed at block: {:?}", data.executed_at); + println!("Success: {}", data.execution_succeeded); + println!("Approvers: {:?}", data.approvers); +} +``` + +#### 2. Get Multiple Proposals with Pagination +```rust +// Get first page (up to 100 results) +let (proposals, next_cursor) = Multisig::get_executed_proposals_paginated( + &multisig_address, + None, // start_after: None for first page + 100 // limit: max results per query +); + +// Process first page +for (hash, data) in proposals { + println!("Proposal {:?}: executed={}", hash, data.execution_succeeded); +} + +// Get next page if more results exist +if let Some(cursor) = next_cursor { + let (more_proposals, next_cursor) = Multisig::get_executed_proposals_paginated( + &multisig_address, + Some(cursor), // Continue from where we left off + 100 + ); + // Process next page... +} +``` + +### DoS Protection + +To prevent denial-of-service attacks via large RPC queries: +- `MaxExecutedProposalsQuery` limits results per query (default: 1000) +- Client-requested limits are capped at this maximum +- Pagination allows iterating through unlimited history safely +- Each multisig's storage is isolated (one large history doesn't affect others) + +### Storage Considerations + +- **No automatic cleanup**: Executed proposals remain in storage indefinitely +- **Cost model**: Proposers pay deposits which cover storage costs +- **Future migration**: If needed, old history can be pruned via runtime upgrade +- **Cancelled/expired proposals**: NOT archived (only events remain) + +**Example: Iterate all executed proposals** +```rust +let mut cursor = None; +let mut all_proposals = Vec::new(); + +loop { + let (proposals, next) = Multisig::get_executed_proposals_paginated( + &multisig_address, + cursor, + 1000 // Max per query + ); + + all_proposals.extend(proposals); + + if next.is_none() { + break; // No more results + } + cursor = next; +} +``` + ## Security Considerations ### Spam Prevention @@ -283,6 +394,7 @@ impl pallet_multisig::Config for Runtime { type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; type GracePeriod = ConstU32<28800>; // ~2 days + type MaxExecutedProposalsQuery = ConstU32<1000>; // Max results per query type PalletId = ConstPalletId(*b"py/mltsg"); type WeightInfo = pallet_multisig::weights::SubstrateWeight; } diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 5d85aa98..2ad1c557 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -101,16 +101,19 @@ mod benchmarks { #[benchmark] fn approve() -> Result<(), BenchmarkError> { // 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(10000u128)); 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); fund_account::(&signer1, BalanceOf2::::from(10000u128)); fund_account::(&signer2, BalanceOf2::::from(10000u128)); + fund_account::(&signer3, BalanceOf2::::from(10000u128)); - let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; - let threshold = 2u32; + 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(); @@ -129,7 +132,7 @@ mod benchmarks { }; Multisigs::::insert(&multisig_address, multisig_data); - // Directly insert proposal into storage + // Directly insert proposal into storage with 1 approval let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; let call = ::RuntimeCall::from(system_call); let encoded_call = call.encode(); @@ -152,16 +155,17 @@ mod benchmarks { #[extrinsic_call] _(RawOrigin::Signed(signer1.clone()), multisig_address.clone(), proposal_hash); - // Verify approval was added + // Verify approval was added (now 2/3, not executed yet) let proposal = Proposals::::get(&multisig_address, proposal_hash).unwrap(); assert!(proposal.approvals.contains(&signer1)); + assert_eq!(proposal.approvals.len(), 2); Ok(()) } #[benchmark] - fn execute() -> Result<(), BenchmarkError> { - // Setup: Create multisig and proposal with enough approvals directly in storage + fn approve_and_execute() -> Result<(), BenchmarkError> { + // Benchmarks approve() when it triggers auto-execution (threshold reached) let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(10000u128)); @@ -190,16 +194,15 @@ mod benchmarks { }; Multisigs::::insert(&multisig_address, multisig_data); - // Directly insert proposal with threshold approvals - // Use RuntimeCall instead of just frame_system::Call + // Directly insert proposal with 1 approval (caller already approved) + // signer2 will approve and trigger execution let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; 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(); - // Already has 2 approvals (threshold met) - let bounded_approvals: BoundedApprovalsOf = - vec![caller.clone(), signer1.clone()].try_into().unwrap(); + // Only 1 approval so far + let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); let proposal_data = ProposalDataOf:: { proposer: caller.clone(), @@ -213,8 +216,9 @@ mod benchmarks { let proposal_hash = ::Hashing::hash_of(&proposal_data.call); Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + // signer2 approves, reaching threshold (2/2), triggering auto-execution #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_hash); + approve(RawOrigin::Signed(signer2.clone()), multisig_address.clone(), proposal_hash); // Verify proposal was executed and removed assert!(!Proposals::::contains_key(&multisig_address, proposal_hash)); diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 85a31155..e64952bf 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -88,6 +88,21 @@ pub struct ProposalData { + /// Account that proposed this transaction + pub proposer: AccountId, + /// The encoded call that was executed + pub call: BoundedCall, + /// List of accounts that approved this proposal + pub approvers: BoundedApprovals, + /// Block number when it was executed + pub executed_at: BlockNumber, + /// Whether the execution succeeded + pub execution_succeeded: bool, +} + /// Balance type type BalanceOf = <::Currency as frame_support::traits::Currency< ::AccountId, @@ -98,10 +113,9 @@ pub mod pallet { use super::*; use codec::Encode; use frame_support::{ - dispatch::{DispatchClass, DispatchResult, GetDispatchInfo, Pays, PostDispatchInfo}, + dispatch::{DispatchResult, GetDispatchInfo, PostDispatchInfo}, pallet_prelude::*, traits::{Currency, ReservableCurrency}, - weights::Weight, PalletId, }; use frame_system::pallet_prelude::*; @@ -156,6 +170,11 @@ pub mod pallet { #[pallet::constant] type GracePeriod: Get>; + /// Maximum number of executed proposals to return in a single query + /// This prevents DoS attacks via large RPC queries + #[pallet::constant] + type MaxExecutedProposalsQuery: Get; + /// Pallet ID for generating multisig addresses #[pallet::constant] type PalletId: Get; @@ -192,6 +211,20 @@ pub mod pallet { BoundedApprovalsOf, >; + /// Type alias for paginated query results + pub type PaginatedProposalsOf = ( + Vec<(::Hash, ExecutedProposalDataOf)>, + Option<::Hash>, + ); + + /// Type alias for ExecutedProposalData with proper bounds + pub type ExecutedProposalDataOf = ExecutedProposalData< + ::AccountId, + BlockNumberFor, + BoundedCallOf, + BoundedApprovalsOf, + >; + /// Global nonce for generating unique multisig addresses #[pallet::storage] pub type GlobalNonce = StorageValue<_, u64, ValueQuery>; @@ -215,6 +248,22 @@ pub mod pallet { OptionQuery, >; + /// Archive of successfully executed proposals + /// Only proposals that were executed successfully are stored here + /// Cancelled or expired proposals are NOT archived + /// + /// Use `get_executed_proposals_paginated()` to query with pagination + #[pallet::storage] + pub type ExecutedProposals = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + T::Hash, + ExecutedProposalDataOf, + OptionQuery, + >; + #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { @@ -518,6 +567,9 @@ 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. + /// /// Parameters: /// - `multisig_address`: The multisig account /// - `proposal_hash`: Hash of the proposal to approve @@ -554,91 +606,29 @@ pub mod pallet { let approvals_count = proposal.approvals.len() as u32; - // Update proposal - Proposals::::insert(&multisig_address, proposal_hash, 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(); - } - }); - - // Emit event + // Emit approval event Self::deposit_event(Event::TransactionApproved { - multisig_address, + multisig_address: multisig_address.clone(), approver, proposal_hash, approvals_count, }); - Ok(()) - } - - /// Execute a transaction once threshold is met - /// - /// Parameters: - /// - `multisig_address`: The multisig account - /// - `proposal_hash`: Hash of the proposal to execute - #[pallet::call_index(3)] - #[pallet::weight(( - Pallet::::execute_weight(multisig_address, proposal_hash), - DispatchClass::Normal, - Pays::Yes - ))] - pub fn execute( - origin: OriginFor, - multisig_address: T::AccountId, - proposal_hash: T::Hash, - ) -> DispatchResult { - let _ = ensure_signed(origin)?; - - // Get multisig data - let multisig_data = - Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; - - // Get proposal - let proposal = Proposals::::get(&multisig_address, proposal_hash) - .ok_or(Error::::ProposalNotFound)?; - - // Check if not expired - let current_block = frame_system::Pallet::::block_number(); - ensure!(current_block <= proposal.expiry, Error::::ProposalExpired); - - // Check if threshold met - ensure!( - proposal.approvals.len() as u32 >= multisig_data.threshold, - Error::::NotEnoughApprovals - ); - - // Decode the call before modifying storage - let call = ::RuntimeCall::decode(&mut &proposal.call[..]) - .map_err(|_| Error::::InvalidCall)?; - - // Return deposit to proposer - T::Currency::unreserve(&proposal.proposer, proposal.deposit); - - // Remove proposal - Proposals::::remove(&multisig_address, proposal_hash); - - // Update multisig: decrement counter and last_activity - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { - multisig.last_activity = frame_system::Pallet::::block_number(); - multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - } - }); - - // Execute the call as the multisig account - let result = - call.dispatch(frame_system::RawOrigin::Signed(multisig_address.clone()).into()); + // Check if threshold is reached - if so, execute immediately + if approvals_count >= multisig_data.threshold { + // Execute the transaction + Self::do_execute(multisig_address, proposal_hash, proposal)?; + } else { + // Not ready yet, just save the proposal + Proposals::::insert(&multisig_address, proposal_hash, proposal); - // Emit event with execution result - Self::deposit_event(Event::TransactionExecuted { - multisig_address, - proposal_hash, - result: result.map(|_| ()).map_err(|e| e.error), - }); + // Update multisig last_activity + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.last_activity = frame_system::Pallet::::block_number(); + } + }); + } Ok(()) } @@ -648,7 +638,7 @@ pub mod pallet { /// Parameters: /// - `multisig_address`: The multisig account /// - `proposal_hash`: Hash of the proposal to cancel - #[pallet::call_index(4)] + #[pallet::call_index(3)] #[pallet::weight(::WeightInfo::cancel())] pub fn cancel( origin: OriginFor, @@ -694,7 +684,7 @@ pub mod pallet { /// - After grace period: anyone can remove, deposit returned to proposer /// /// This ensures storage cleanup while giving proposers time to act. - #[pallet::call_index(5)] + #[pallet::call_index(4)] #[pallet::weight(::WeightInfo::remove_expired())] pub fn remove_expired( origin: OriginFor, @@ -758,7 +748,7 @@ pub mod pallet { /// - Single transaction to clean up all user's old deposits /// /// Use this after grace period to recover all your deposits at once. - #[pallet::call_index(6)] + #[pallet::call_index(5)] #[pallet::weight(::WeightInfo::claim_deposits())] pub fn claim_deposits( origin: OriginFor, @@ -854,25 +844,6 @@ pub mod pallet { } impl Pallet { - /// Weight for `execute` is base multisig cost + dispatched call weight. - /// - /// This prevents underpaying (DoS economics) by ensuring the extrinsic weight - /// covers the worst-case weight of the stored call. - pub fn execute_weight(multisig_address: &T::AccountId, proposal_hash: &T::Hash) -> Weight { - let base = ::WeightInfo::execute(); - - // Best-effort: if proposal/call decoding fails, fall back to base weight. - let Some(proposal) = Proposals::::get(multisig_address, proposal_hash) else { - return base; - }; - - let Ok(call) = ::RuntimeCall::decode(&mut &proposal.call[..]) else { - return base; - }; - - base.saturating_add(call.get_dispatch_info().call_weight) - } - /// 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. @@ -902,5 +873,130 @@ pub mod pallet { false } } + + /// Get a single executed proposal from archive + /// Returns None if proposal was never executed or was cancelled/expired + pub fn get_executed_proposal( + multisig_address: &T::AccountId, + proposal_hash: &T::Hash, + ) -> Option> { + ExecutedProposals::::get(multisig_address, proposal_hash) + } + + /// Get executed proposals for a multisig with pagination + /// + /// # Arguments + /// * `multisig_address` - The multisig account to query + /// * `start_after` - Optional hash to start after (for pagination) + /// * `limit` - Maximum number of results to return (capped at MaxExecutedProposalsQuery) + /// + /// # Returns + /// * `Vec<(T::Hash, ExecutedProposalDataOf)>` - List of (proposal_hash, data) pairs + /// * `Option` - Next cursor for pagination (Some if more results exist, None if + /// end) + /// + /// # Example + /// ```ignore + /// // First page + /// let (proposals, next_cursor) = Multisig::get_executed_proposals_paginated(&multisig, None, 100); + /// + /// // Next page (if next_cursor is Some) + /// if let Some(cursor) = next_cursor { + /// let (more_proposals, next_cursor) = Multisig::get_executed_proposals_paginated(&multisig, Some(cursor), 100); + /// } + /// ``` + pub fn get_executed_proposals_paginated( + multisig_address: &T::AccountId, + start_after: Option, + limit: u32, + ) -> PaginatedProposalsOf { + // Cap limit at configured maximum + let max_limit = T::MaxExecutedProposalsQuery::get().min(limit); + + let iter = ExecutedProposals::::iter_prefix(multisig_address); + + let mut results = Vec::new(); + let mut next_cursor = None; + + // If start_after is provided, we need to skip until we find it + let mut found_start = start_after.is_none(); // If no start_after, we're already "found" + + for (hash, data) in iter { + // Skip until we pass start_after + if !found_start { + if Some(&hash) == start_after.as_ref() { + found_start = true; // Mark as found + } + continue; // Skip this element (including start_after itself) + } + + // Now we're past start_after (or there was no start_after) + // Collect results up to max_limit + if results.len() < max_limit as usize { + results.push((hash, data)); + } else { + // We have one more result beyond the limit, so there's a next page + // Use the last element we collected as the cursor for next page + next_cursor = Some(results.last().unwrap().0); + break; + } + } + + (results, next_cursor) + } + + /// Internal function to execute a proposal + /// Called automatically from `approve()` when threshold is reached + /// + /// This function is private and cannot be called from outside the pallet + fn do_execute( + multisig_address: T::AccountId, + proposal_hash: T::Hash, + proposal: ProposalDataOf, + ) -> DispatchResult { + // Decode the call before modifying storage + let call = ::RuntimeCall::decode(&mut &proposal.call[..]) + .map_err(|_| Error::::InvalidCall)?; + + // Return deposit to proposer + T::Currency::unreserve(&proposal.proposer, proposal.deposit); + + // Execute the call as the multisig account + let result = + call.dispatch(frame_system::RawOrigin::Signed(multisig_address.clone()).into()); + + let execution_succeeded = result.is_ok(); + + // Archive the executed proposal (for successful executions only in terms of storage, + // but we store all executed proposals with their result) + let executed_proposal = ExecutedProposalDataOf:: { + proposer: proposal.proposer.clone(), + call: proposal.call.clone(), + approvers: proposal.approvals.clone(), + executed_at: frame_system::Pallet::::block_number(), + execution_succeeded, + }; + ExecutedProposals::::insert(&multisig_address, proposal_hash, executed_proposal); + + // Remove proposal from active storage + Proposals::::remove(&multisig_address, proposal_hash); + + // Update multisig: decrement counter and update last_activity + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.last_activity = frame_system::Pallet::::block_number(); + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); + } + }); + + // Emit event with execution result + Self::deposit_event(Event::TransactionExecuted { + multisig_address, + proposal_hash, + result: result.map(|_| ()).map_err(|e| e.error), + }); + + Ok(()) + } } } diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index f611dc41..f80c369a 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -95,6 +95,7 @@ parameter_types! { pub const ProposalDepositParam: Balance = 10; pub const ProposalFeeParam: Balance = 5; // Non-refundable fee pub const GracePeriodParam: u64 = 100; // 100 blocks for testing + pub const MaxExecutedProposalsQueryParam: u32 = 100; // Max results per query } impl pallet_multisig::Config for Test { @@ -108,6 +109,7 @@ impl pallet_multisig::Config for Test { type ProposalDeposit = ProposalDepositParam; type ProposalFee = ProposalFeeParam; type GracePeriod = GracePeriodParam; + type MaxExecutedProposalsQuery = MaxExecutedProposalsQueryParam; type PalletId = MultisigPalletId; type WeightInfo = (); } diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 7659da10..f4d0398b 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -239,10 +239,10 @@ fn max_active_proposals_limit_works() { call1.try_into().unwrap(); let hash1 = ::Hashing::hash_of(&bounded); + // Approve by charlie - this will auto-execute (threshold 2 reached: bob + charlie) assert_ok!(Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, hash1)); - assert_ok!(Multisig::execute(RuntimeOrigin::signed(alice()), multisig_address, hash1)); - // Check counter decreased + // Check counter decreased (proposal auto-executed) let multisig_data = Multisigs::::get(multisig_address).unwrap(); assert_eq!(multisig_data.active_proposals, 9); @@ -460,10 +460,10 @@ fn approve_works() { new_test_ext().execute_with(|| { System::set_block_number(1); - // Create multisig + // Create multisig with threshold 3 (so approve won't trigger execution) let creator = alice(); let signers = vec![bob(), charlie(), dave()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 3)); let multisig_address = Multisig::derive_multisig_address(&signers, 0); @@ -478,14 +478,14 @@ fn approve_works() { let proposal_hash = ::Hashing::hash_of(&call); - // Approve + // Approve (now 2/3, not executed yet) assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); - // Check approval was added + // Check approval was added (proposal still exists, not executed) let proposal = crate::Proposals::::get(multisig_address, proposal_hash).unwrap(); assert_eq!(proposal.approvals.len(), 2); // bob + charlie assert!(proposal.approvals.contains(&charlie())); @@ -522,7 +522,7 @@ fn approve_fails_if_already_approved() { } #[test] -fn execute_works() { +fn approve_auto_executes_when_threshold_reached() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -547,37 +547,31 @@ fn execute_works() { let proposal_hash = ::Hashing::hash_of(&call); - // Approve to reach threshold + // Approve by charlie to reach threshold (bob auto-approved in propose) + // This should automatically execute the transaction assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); - // Execute - assert_ok!(Multisig::execute( - RuntimeOrigin::signed(alice()), - multisig_address, - proposal_hash - )); - // Check deposit was returned, but fee was NOT returned assert_eq!(Balances::reserved_balance(proposer), 0); assert_eq!(Balances::free_balance(proposer), initial_balance - proposal_fee); // Only fee lost - // Check proposal was removed + // Check proposal was removed (auto-executed) assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); }); } #[test] -fn execute_fails_if_expired_even_if_threshold_met() { +fn approve_fails_if_expired() { new_test_ext().execute_with(|| { System::set_block_number(1); let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + let signers = vec![bob(), charlie(), dave()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 3)); // Need 3 approvals let multisig_address = Multisig::derive_multisig_address(&signers, 0); @@ -599,24 +593,19 @@ fn execute_fails_if_expired_even_if_threshold_met() { }; let proposal_hash = get_hash(call); - // Reach threshold before expiry (bob auto-approves in propose) - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Move past expiry and attempt to execute + // Move past expiry System::set_block_number(expiry + 1); + + // Attempt to approve after expiry should fail assert_noop!( - Multisig::execute(RuntimeOrigin::signed(alice()), multisig_address, proposal_hash), + Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash), Error::::ProposalExpired ); }); } #[test] -fn execute_fails_without_threshold() { +fn approve_does_not_execute_without_threshold() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -636,18 +625,25 @@ fn execute_fails_without_threshold() { let proposal_hash = ::Hashing::hash_of(&call); - // Only 2 approvals (bob + charlie), need 3 + // Only 2 approvals (bob + charlie), need 3 - should NOT auto-execute assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); - // Try to execute without threshold - assert_noop!( - Multisig::execute(RuntimeOrigin::signed(alice()), multisig_address, proposal_hash), - Error::::NotEnoughApprovals - ); + // Proposal should still exist (not executed yet) + assert!(crate::Proposals::::contains_key(multisig_address, proposal_hash)); + + // Now approve by dave to reach threshold - should auto-execute + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(dave()), + multisig_address, + proposal_hash + )); + + // Proposal should be removed (auto-executed) + assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); }); } @@ -787,12 +783,11 @@ fn proposal_fee_is_never_returned() { initial_balance - 2 * proposal_deposit - 3 * proposal_fee ); - // Execute another proposal + // Approve another proposal (auto-executes when threshold reached) let hash2 = get_hash(call2); assert_ok!(Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, hash2)); - assert_ok!(Multisig::execute(RuntimeOrigin::signed(alice()), multisig_address, hash2)); - // After execute: 1 deposit reserved + 3 fees still lost + // After auto-execute: 1 deposit reserved + 3 fees still lost assert_eq!(Balances::reserved_balance(proposer), proposal_deposit); assert_eq!( Balances::free_balance(proposer), @@ -803,6 +798,84 @@ fn proposal_fee_is_never_returned() { }); } +// ==================== EXECUTED PROPOSALS ARCHIVE TESTS ==================== + +#[test] +fn executed_proposals_are_archived() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + 1000 + )); + + let proposal_hash = ::Hashing::hash_of(&call); + + // Approve and auto-execute + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Proposal should be removed from active storage + assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); + + // But should exist in archive + let archived = Multisig::get_executed_proposal(&multisig_address, &proposal_hash); + assert!(archived.is_some()); + + let archived_data = archived.unwrap(); + assert_eq!(archived_data.proposer, bob()); + assert_eq!(archived_data.call.to_vec(), call); + assert_eq!(archived_data.approvers.len(), 2); // bob + charlie + assert!(archived_data.approvers.contains(&bob())); + assert!(archived_data.approvers.contains(&charlie())); + assert_eq!(archived_data.executed_at, 1); + assert_eq!(archived_data.execution_succeeded, true); + }); +} + +#[test] +fn cancelled_proposals_are_not_archived() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + 1000 + )); + + let proposal_hash = ::Hashing::hash_of(&call); + + // Cancel the proposal + assert_ok!(Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, proposal_hash)); + + // Should NOT be in archive + let archived = Multisig::get_executed_proposal(&multisig_address, &proposal_hash); + assert!(archived.is_none()); + }); +} + // ==================== EXPIRED PROPOSAL CLEANUP TESTS ==================== #[test] @@ -1276,3 +1349,203 @@ fn claim_deposits_works_for_mixed_proposals() { assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call3))); }); } + +// ==================== PAGINATION TESTS ==================== + +#[test] +fn get_executed_proposals_paginated_works() { + new_test_ext().execute_with(|| { + let signers = vec![alice(), bob()]; + let threshold = 2; + + // Create multisig + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(alice()), + signers.clone(), + threshold + )); + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + + // Create and execute 5 proposals + let mut proposal_hashes = Vec::new(); + for i in 0..5 { + let call = make_call(vec![i as u8; 32]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(alice()), + multisig_address.clone(), + call.clone(), + 1000 + )); + let hash = get_hash(call.clone()); + proposal_hashes.push(hash); + + // Approve and execute + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + hash + )); + } + + // Test: Get first page with limit 2 + let (first_page, cursor1) = + Multisig::get_executed_proposals_paginated(&multisig_address, None, 2); + assert_eq!(first_page.len(), 2); + assert!(cursor1.is_some()); + + // Test: Get second page + let (second_page, cursor2) = + Multisig::get_executed_proposals_paginated(&multisig_address, cursor1, 2); + assert_eq!(second_page.len(), 2); + assert!(cursor2.is_some()); + + // Test: Get third page (only 1 remaining) + let (third_page, cursor3) = + Multisig::get_executed_proposals_paginated(&multisig_address, cursor2, 2); + assert_eq!(third_page.len(), 1); + assert!(cursor3.is_none()); // No more results + + // Verify all proposals are unique + let mut all_hashes: Vec<&::Hash> = Vec::new(); + all_hashes.extend(first_page.iter().map(|(h, _)| h)); + all_hashes.extend(second_page.iter().map(|(h, _)| h)); + all_hashes.extend(third_page.iter().map(|(h, _)| h)); + assert_eq!(all_hashes.len(), 5); + + // Verify all are marked as successfully executed + for (_, data) in first_page.iter().chain(second_page.iter()).chain(third_page.iter()) { + assert!(data.execution_succeeded); + } + }); +} + +#[test] +fn get_executed_proposals_paginated_respects_max_limit() { + new_test_ext().execute_with(|| { + let signers = vec![alice(), bob()]; + let threshold = 2; + + // Create multisig + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(alice()), + signers.clone(), + threshold + )); + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + + // Create and execute 10 proposals + for i in 0..10 { + let call = make_call(vec![i as u8; 32]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(alice()), + multisig_address.clone(), + call.clone(), + 1000 + )); + let hash = get_hash(call.clone()); + + // Approve and execute + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + hash + )); + } + + // Test: Request 1000 items, should be capped at MaxExecutedProposalsQuery (100 in mock) + let (results, _) = + Multisig::get_executed_proposals_paginated(&multisig_address, None, 1000); + assert_eq!(results.len(), 10); // We only have 10, but limit should be enforced + + // Test: Request exact limit + let (results, cursor) = + Multisig::get_executed_proposals_paginated(&multisig_address, None, 100); + assert_eq!(results.len(), 10); + assert!(cursor.is_none()); // No more results + }); +} + +#[test] +fn get_executed_proposals_paginated_empty_multisig() { + new_test_ext().execute_with(|| { + let signers = vec![alice(), bob()]; + let threshold = 2; + + // Create multisig but don't execute anything + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(alice()), + signers.clone(), + threshold + )); + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Test: Query empty multisig + let (results, cursor) = + Multisig::get_executed_proposals_paginated(&multisig_address, None, 100); + assert_eq!(results.len(), 0); + assert!(cursor.is_none()); + }); +} + +#[test] +fn get_executed_proposal_single_item_works() { + new_test_ext().execute_with(|| { + let signers = vec![alice(), bob()]; + let threshold = 2; + + // Create multisig + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(alice()), + signers.clone(), + threshold + )); + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let get_hash = |call: Vec| { + use frame_support::BoundedVec; + let bounded: BoundedVec::MaxCallSize> = + call.try_into().unwrap(); + ::Hashing::hash_of(&bounded) + }; + + // Create and execute proposal + let call = make_call(vec![1; 32]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(alice()), + multisig_address.clone(), + call.clone(), + 1000 + )); + let hash = get_hash(call.clone()); + + assert_ok!(Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address.clone(), hash)); + + // Test: Get single proposal by hash + let proposal = Multisig::get_executed_proposal(&multisig_address, &hash); + assert!(proposal.is_some()); + let proposal_data = proposal.unwrap(); + assert_eq!(proposal_data.proposer, alice()); + assert!(proposal_data.execution_succeeded); + assert_eq!(proposal_data.approvers.len(), 2); + + // Test: Query non-existent proposal + let fake_call = make_call(vec![99; 32]); + let fake_hash = get_hash(fake_call); + let result = Multisig::get_executed_proposal(&multisig_address, &fake_hash); + assert!(result.is_none()); + }); +} diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index fc8b2307..3eb9b733 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -50,7 +50,7 @@ pub trait WeightInfo { fn create_multisig() -> Weight; fn propose() -> Weight; fn approve() -> Weight; - fn execute() -> Weight; + fn approve_and_execute() -> Weight; fn cancel() -> Weight; fn remove_expired() -> Weight; fn claim_deposits() -> Weight; @@ -102,7 +102,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) - fn execute() -> Weight { + fn approve_and_execute() -> Weight { // Proof Size summary in bytes: // Measured: `822` // Estimated: `17057` @@ -196,7 +196,7 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) - fn execute() -> Weight { + fn approve_and_execute() -> Weight { // Proof Size summary in bytes: // Measured: `822` // Estimated: `17057` diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 31358ee5..cfb7da14 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -578,6 +578,7 @@ parameter_types! { pub const ProposalDeposit: Balance = 1000 * MILLI_UNIT; // 1 UNIT (refundable) pub const ProposalFee: Balance = 1000 * MILLI_UNIT; // 1 UNIT (non-refundable) pub const ProposalGracePeriod: BlockNumber = 28800; // ~2 days (6s blocks) + pub const MaxExecutedProposalsQuery: u32 = 1000; // Max results per query (DoS protection) } impl pallet_multisig::Config for Runtime { @@ -591,6 +592,7 @@ impl pallet_multisig::Config for Runtime { type ProposalDeposit = ProposalDeposit; type ProposalFee = ProposalFee; type GracePeriod = ProposalGracePeriod; + type MaxExecutedProposalsQuery = MaxExecutedProposalsQuery; type PalletId = MultisigPalletId; type WeightInfo = pallet_multisig::weights::SubstrateWeight; } From 452cd40a99355637f13b8b9cc7ad2e973e0d2797 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Mon, 19 Jan 2026 11:54:29 +0800 Subject: [PATCH 07/27] draft: Historical proposals - from events only --- pallets/multisig/README.md | 101 ++++-------- pallets/multisig/src/lib.rs | 144 +----------------- pallets/multisig/src/mock.rs | 2 - pallets/multisig/src/tests.rs | 278 ---------------------------------- runtime/src/configs/mod.rs | 2 - 5 files changed, 37 insertions(+), 490 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index dcc5c0cb..e9f26845 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -272,89 +272,47 @@ create_multisig([charlie, bob, alice], 2) // → multisig_addr_1 (SAME! nonce wo create_multisig([alice, bob, charlie], 2) // → multisig_addr_2 (nonce=1, different address) ``` -## Querying Executed Proposals +## Historical Data and Event Indexing -The pallet maintains a permanent archive of successfully executed proposals in the `ExecutedProposals` storage. This archive includes: -- Proposer account -- Encoded call that was executed -- List of approvers -- Execution timestamp (block number) -- Execution result (success/failure) +The pallet does **not** maintain on-chain storage of executed proposal history. Instead, all historical data is available through **blockchain events**, which are designed to be efficiently indexed by off-chain indexers like **SubSquid**. -### Query Methods +### TransactionExecuted Event -#### 1. Get Single Proposal -```rust -// Query by multisig address and proposal hash -let proposal = Multisig::get_executed_proposal(&multisig_address, &proposal_hash); -if let Some(data) = proposal { - println!("Proposer: {:?}", data.proposer); - println!("Executed at block: {:?}", data.executed_at); - println!("Success: {}", data.execution_succeeded); - println!("Approvers: {:?}", data.approvers); -} -``` +When a proposal is successfully executed, the pallet emits a comprehensive `TransactionExecuted` event containing all relevant data: -#### 2. Get Multiple Proposals with Pagination ```rust -// Get first page (up to 100 results) -let (proposals, next_cursor) = Multisig::get_executed_proposals_paginated( - &multisig_address, - None, // start_after: None for first page - 100 // limit: max results per query -); - -// Process first page -for (hash, data) in proposals { - println!("Proposal {:?}: executed={}", hash, data.execution_succeeded); -} - -// Get next page if more results exist -if let Some(cursor) = next_cursor { - let (more_proposals, next_cursor) = Multisig::get_executed_proposals_paginated( - &multisig_address, - Some(cursor), // Continue from where we left off - 100 - ); - // Process next page... +Event::TransactionExecuted { + multisig_address: T::AccountId, // The multisig that executed + proposal_hash: T::Hash, // Hash of the proposal + proposer: T::AccountId, // Who originally proposed it + call: Vec, // The encoded call that was executed + approvers: Vec, // All accounts that approved + result: DispatchResult, // Whether execution succeeded or failed } ``` -### DoS Protection +### Indexing with SubSquid -To prevent denial-of-service attacks via large RPC queries: -- `MaxExecutedProposalsQuery` limits results per query (default: 1000) -- Client-requested limits are capped at this maximum -- Pagination allows iterating through unlimited history safely -- Each multisig's storage is isolated (one large history doesn't affect others) +This event structure is optimized for indexing by SubSquid and similar indexers: +- **Complete data**: All information needed to reconstruct the full proposal history +- **Queryable**: Indexers can efficiently query by multisig address, proposer, approvers, etc. +- **Execution result**: Both successful and failed executions are recorded +- **No storage bloat**: Events don't consume on-chain storage long-term -### Storage Considerations +**Other events** for complete history: +- `MultisigCreated` - When a multisig is created +- `TransactionProposed` - When a proposal is submitted +- `TransactionApproved` - Each time someone approves (includes current approval count) +- `TransactionCancelled` - When a proposal is cancelled +- `ProposalExpired` - When a proposal expires -- **No automatic cleanup**: Executed proposals remain in storage indefinitely -- **Cost model**: Proposers pay deposits which cover storage costs -- **Future migration**: If needed, old history can be pruned via runtime upgrade -- **Cancelled/expired proposals**: NOT archived (only events remain) +### Benefits of Event-Based History -**Example: Iterate all executed proposals** -```rust -let mut cursor = None; -let mut all_proposals = Vec::new(); - -loop { - let (proposals, next) = Multisig::get_executed_proposals_paginated( - &multisig_address, - cursor, - 1000 // Max per query - ); - - all_proposals.extend(proposals); - - if next.is_none() { - break; // No more results - } - cursor = next; -} -``` +- ✅ **No storage costs**: Events don't occupy chain storage after archival +- ✅ **Complete history**: All actions are recorded permanently in events +- ✅ **Efficient querying**: Off-chain indexers provide fast, flexible queries +- ✅ **No DoS risk**: No on-chain iteration over unbounded storage +- ✅ **Standard practice**: Follows Substrate best practices for historical data ## Security Considerations @@ -394,7 +352,6 @@ impl pallet_multisig::Config for Runtime { type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; type GracePeriod = ConstU32<28800>; // ~2 days - type MaxExecutedProposalsQuery = ConstU32<1000>; // Max results per query type PalletId = ConstPalletId(*b"py/mltsg"); type WeightInfo = pallet_multisig::weights::SubstrateWeight; } diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index e64952bf..45c6a9b6 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -88,21 +88,6 @@ pub struct ProposalData { - /// Account that proposed this transaction - pub proposer: AccountId, - /// The encoded call that was executed - pub call: BoundedCall, - /// List of accounts that approved this proposal - pub approvers: BoundedApprovals, - /// Block number when it was executed - pub executed_at: BlockNumber, - /// Whether the execution succeeded - pub execution_succeeded: bool, -} - /// Balance type type BalanceOf = <::Currency as frame_support::traits::Currency< ::AccountId, @@ -170,11 +155,6 @@ pub mod pallet { #[pallet::constant] type GracePeriod: Get>; - /// Maximum number of executed proposals to return in a single query - /// This prevents DoS attacks via large RPC queries - #[pallet::constant] - type MaxExecutedProposalsQuery: Get; - /// Pallet ID for generating multisig addresses #[pallet::constant] type PalletId: Get; @@ -211,20 +191,6 @@ pub mod pallet { BoundedApprovalsOf, >; - /// Type alias for paginated query results - pub type PaginatedProposalsOf = ( - Vec<(::Hash, ExecutedProposalDataOf)>, - Option<::Hash>, - ); - - /// Type alias for ExecutedProposalData with proper bounds - pub type ExecutedProposalDataOf = ExecutedProposalData< - ::AccountId, - BlockNumberFor, - BoundedCallOf, - BoundedApprovalsOf, - >; - /// Global nonce for generating unique multisig addresses #[pallet::storage] pub type GlobalNonce = StorageValue<_, u64, ValueQuery>; @@ -248,22 +214,6 @@ pub mod pallet { OptionQuery, >; - /// Archive of successfully executed proposals - /// Only proposals that were executed successfully are stored here - /// Cancelled or expired proposals are NOT archived - /// - /// Use `get_executed_proposals_paginated()` to query with pagination - #[pallet::storage] - pub type ExecutedProposals = StorageDoubleMap< - _, - Blake2_128Concat, - T::AccountId, - Blake2_128Concat, - T::Hash, - ExecutedProposalDataOf, - OptionQuery, - >; - #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { @@ -292,10 +242,13 @@ pub mod pallet { approvals_count: u32, }, /// A transaction has been executed - /// [multisig_address, proposal_hash, result] + /// Contains all data needed for indexing by SubSquid TransactionExecuted { multisig_address: T::AccountId, proposal_hash: T::Hash, + proposer: T::AccountId, + call: Vec, + approvers: Vec, result: DispatchResult, }, /// A transaction has been cancelled @@ -874,77 +827,6 @@ pub mod pallet { } } - /// Get a single executed proposal from archive - /// Returns None if proposal was never executed or was cancelled/expired - pub fn get_executed_proposal( - multisig_address: &T::AccountId, - proposal_hash: &T::Hash, - ) -> Option> { - ExecutedProposals::::get(multisig_address, proposal_hash) - } - - /// Get executed proposals for a multisig with pagination - /// - /// # Arguments - /// * `multisig_address` - The multisig account to query - /// * `start_after` - Optional hash to start after (for pagination) - /// * `limit` - Maximum number of results to return (capped at MaxExecutedProposalsQuery) - /// - /// # Returns - /// * `Vec<(T::Hash, ExecutedProposalDataOf)>` - List of (proposal_hash, data) pairs - /// * `Option` - Next cursor for pagination (Some if more results exist, None if - /// end) - /// - /// # Example - /// ```ignore - /// // First page - /// let (proposals, next_cursor) = Multisig::get_executed_proposals_paginated(&multisig, None, 100); - /// - /// // Next page (if next_cursor is Some) - /// if let Some(cursor) = next_cursor { - /// let (more_proposals, next_cursor) = Multisig::get_executed_proposals_paginated(&multisig, Some(cursor), 100); - /// } - /// ``` - pub fn get_executed_proposals_paginated( - multisig_address: &T::AccountId, - start_after: Option, - limit: u32, - ) -> PaginatedProposalsOf { - // Cap limit at configured maximum - let max_limit = T::MaxExecutedProposalsQuery::get().min(limit); - - let iter = ExecutedProposals::::iter_prefix(multisig_address); - - let mut results = Vec::new(); - let mut next_cursor = None; - - // If start_after is provided, we need to skip until we find it - let mut found_start = start_after.is_none(); // If no start_after, we're already "found" - - for (hash, data) in iter { - // Skip until we pass start_after - if !found_start { - if Some(&hash) == start_after.as_ref() { - found_start = true; // Mark as found - } - continue; // Skip this element (including start_after itself) - } - - // Now we're past start_after (or there was no start_after) - // Collect results up to max_limit - if results.len() < max_limit as usize { - results.push((hash, data)); - } else { - // We have one more result beyond the limit, so there's a next page - // Use the last element we collected as the cursor for next page - next_cursor = Some(results.last().unwrap().0); - break; - } - } - - (results, next_cursor) - } - /// Internal function to execute a proposal /// Called automatically from `approve()` when threshold is reached /// @@ -965,19 +847,6 @@ pub mod pallet { let result = call.dispatch(frame_system::RawOrigin::Signed(multisig_address.clone()).into()); - let execution_succeeded = result.is_ok(); - - // Archive the executed proposal (for successful executions only in terms of storage, - // but we store all executed proposals with their result) - let executed_proposal = ExecutedProposalDataOf:: { - proposer: proposal.proposer.clone(), - call: proposal.call.clone(), - approvers: proposal.approvals.clone(), - executed_at: frame_system::Pallet::::block_number(), - execution_succeeded, - }; - ExecutedProposals::::insert(&multisig_address, proposal_hash, executed_proposal); - // Remove proposal from active storage Proposals::::remove(&multisig_address, proposal_hash); @@ -989,10 +858,13 @@ pub mod pallet { } }); - // Emit event with execution result + // Emit event with all execution details for SubSquid indexing Self::deposit_event(Event::TransactionExecuted { multisig_address, proposal_hash, + proposer: proposal.proposer, + call: proposal.call.to_vec(), + approvers: proposal.approvals.to_vec(), result: result.map(|_| ()).map_err(|e| e.error), }); diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index f80c369a..f611dc41 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -95,7 +95,6 @@ parameter_types! { pub const ProposalDepositParam: Balance = 10; pub const ProposalFeeParam: Balance = 5; // Non-refundable fee pub const GracePeriodParam: u64 = 100; // 100 blocks for testing - pub const MaxExecutedProposalsQueryParam: u32 = 100; // Max results per query } impl pallet_multisig::Config for Test { @@ -109,7 +108,6 @@ impl pallet_multisig::Config for Test { type ProposalDeposit = ProposalDepositParam; type ProposalFee = ProposalFeeParam; type GracePeriod = GracePeriodParam; - type MaxExecutedProposalsQuery = MaxExecutedProposalsQueryParam; type PalletId = MultisigPalletId; type WeightInfo = (); } diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index f4d0398b..61e1420f 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -798,84 +798,6 @@ fn proposal_fee_is_never_returned() { }); } -// ==================== EXECUTED PROPOSALS ARCHIVE TESTS ==================== - -#[test] -fn executed_proposals_are_archived() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let call = make_call(vec![1, 2, 3]); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call.clone(), - 1000 - )); - - let proposal_hash = ::Hashing::hash_of(&call); - - // Approve and auto-execute - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Proposal should be removed from active storage - assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); - - // But should exist in archive - let archived = Multisig::get_executed_proposal(&multisig_address, &proposal_hash); - assert!(archived.is_some()); - - let archived_data = archived.unwrap(); - assert_eq!(archived_data.proposer, bob()); - assert_eq!(archived_data.call.to_vec(), call); - assert_eq!(archived_data.approvers.len(), 2); // bob + charlie - assert!(archived_data.approvers.contains(&bob())); - assert!(archived_data.approvers.contains(&charlie())); - assert_eq!(archived_data.executed_at, 1); - assert_eq!(archived_data.execution_succeeded, true); - }); -} - -#[test] -fn cancelled_proposals_are_not_archived() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let call = make_call(vec![1, 2, 3]); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call.clone(), - 1000 - )); - - let proposal_hash = ::Hashing::hash_of(&call); - - // Cancel the proposal - assert_ok!(Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, proposal_hash)); - - // Should NOT be in archive - let archived = Multisig::get_executed_proposal(&multisig_address, &proposal_hash); - assert!(archived.is_none()); - }); -} - // ==================== EXPIRED PROPOSAL CLEANUP TESTS ==================== #[test] @@ -1349,203 +1271,3 @@ fn claim_deposits_works_for_mixed_proposals() { assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call3))); }); } - -// ==================== PAGINATION TESTS ==================== - -#[test] -fn get_executed_proposals_paginated_works() { - new_test_ext().execute_with(|| { - let signers = vec![alice(), bob()]; - let threshold = 2; - - // Create multisig - assert_ok!(Multisig::create_multisig( - RuntimeOrigin::signed(alice()), - signers.clone(), - threshold - )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - - // Create and execute 5 proposals - let mut proposal_hashes = Vec::new(); - for i in 0..5 { - let call = make_call(vec![i as u8; 32]); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(alice()), - multisig_address.clone(), - call.clone(), - 1000 - )); - let hash = get_hash(call.clone()); - proposal_hashes.push(hash); - - // Approve and execute - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(bob()), - multisig_address.clone(), - hash - )); - } - - // Test: Get first page with limit 2 - let (first_page, cursor1) = - Multisig::get_executed_proposals_paginated(&multisig_address, None, 2); - assert_eq!(first_page.len(), 2); - assert!(cursor1.is_some()); - - // Test: Get second page - let (second_page, cursor2) = - Multisig::get_executed_proposals_paginated(&multisig_address, cursor1, 2); - assert_eq!(second_page.len(), 2); - assert!(cursor2.is_some()); - - // Test: Get third page (only 1 remaining) - let (third_page, cursor3) = - Multisig::get_executed_proposals_paginated(&multisig_address, cursor2, 2); - assert_eq!(third_page.len(), 1); - assert!(cursor3.is_none()); // No more results - - // Verify all proposals are unique - let mut all_hashes: Vec<&::Hash> = Vec::new(); - all_hashes.extend(first_page.iter().map(|(h, _)| h)); - all_hashes.extend(second_page.iter().map(|(h, _)| h)); - all_hashes.extend(third_page.iter().map(|(h, _)| h)); - assert_eq!(all_hashes.len(), 5); - - // Verify all are marked as successfully executed - for (_, data) in first_page.iter().chain(second_page.iter()).chain(third_page.iter()) { - assert!(data.execution_succeeded); - } - }); -} - -#[test] -fn get_executed_proposals_paginated_respects_max_limit() { - new_test_ext().execute_with(|| { - let signers = vec![alice(), bob()]; - let threshold = 2; - - // Create multisig - assert_ok!(Multisig::create_multisig( - RuntimeOrigin::signed(alice()), - signers.clone(), - threshold - )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - - // Create and execute 10 proposals - for i in 0..10 { - let call = make_call(vec![i as u8; 32]); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(alice()), - multisig_address.clone(), - call.clone(), - 1000 - )); - let hash = get_hash(call.clone()); - - // Approve and execute - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(bob()), - multisig_address.clone(), - hash - )); - } - - // Test: Request 1000 items, should be capped at MaxExecutedProposalsQuery (100 in mock) - let (results, _) = - Multisig::get_executed_proposals_paginated(&multisig_address, None, 1000); - assert_eq!(results.len(), 10); // We only have 10, but limit should be enforced - - // Test: Request exact limit - let (results, cursor) = - Multisig::get_executed_proposals_paginated(&multisig_address, None, 100); - assert_eq!(results.len(), 10); - assert!(cursor.is_none()); // No more results - }); -} - -#[test] -fn get_executed_proposals_paginated_empty_multisig() { - new_test_ext().execute_with(|| { - let signers = vec![alice(), bob()]; - let threshold = 2; - - // Create multisig but don't execute anything - assert_ok!(Multisig::create_multisig( - RuntimeOrigin::signed(alice()), - signers.clone(), - threshold - )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - // Test: Query empty multisig - let (results, cursor) = - Multisig::get_executed_proposals_paginated(&multisig_address, None, 100); - assert_eq!(results.len(), 0); - assert!(cursor.is_none()); - }); -} - -#[test] -fn get_executed_proposal_single_item_works() { - new_test_ext().execute_with(|| { - let signers = vec![alice(), bob()]; - let threshold = 2; - - // Create multisig - assert_ok!(Multisig::create_multisig( - RuntimeOrigin::signed(alice()), - signers.clone(), - threshold - )); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - - // Create and execute proposal - let call = make_call(vec![1; 32]); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(alice()), - multisig_address.clone(), - call.clone(), - 1000 - )); - let hash = get_hash(call.clone()); - - assert_ok!(Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address.clone(), hash)); - - // Test: Get single proposal by hash - let proposal = Multisig::get_executed_proposal(&multisig_address, &hash); - assert!(proposal.is_some()); - let proposal_data = proposal.unwrap(); - assert_eq!(proposal_data.proposer, alice()); - assert!(proposal_data.execution_succeeded); - assert_eq!(proposal_data.approvers.len(), 2); - - // Test: Query non-existent proposal - let fake_call = make_call(vec![99; 32]); - let fake_hash = get_hash(fake_call); - let result = Multisig::get_executed_proposal(&multisig_address, &fake_hash); - assert!(result.is_none()); - }); -} diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index cfb7da14..31358ee5 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -578,7 +578,6 @@ parameter_types! { pub const ProposalDeposit: Balance = 1000 * MILLI_UNIT; // 1 UNIT (refundable) pub const ProposalFee: Balance = 1000 * MILLI_UNIT; // 1 UNIT (non-refundable) pub const ProposalGracePeriod: BlockNumber = 28800; // ~2 days (6s blocks) - pub const MaxExecutedProposalsQuery: u32 = 1000; // Max results per query (DoS protection) } impl pallet_multisig::Config for Runtime { @@ -592,7 +591,6 @@ impl pallet_multisig::Config for Runtime { type ProposalDeposit = ProposalDeposit; type ProposalFee = ProposalFee; type GracePeriod = ProposalGracePeriod; - type MaxExecutedProposalsQuery = MaxExecutedProposalsQuery; type PalletId = MultisigPalletId; type WeightInfo = pallet_multisig::weights::SubstrateWeight; } From b40676550d888be626a212dec2467418c9e7ff0f Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Mon, 19 Jan 2026 13:41:10 +0800 Subject: [PATCH 08/27] ref: Events renamed + Deposits logic simplified --- pallets/multisig/README.md | 18 +- pallets/multisig/src/benchmarking.rs | 25 +- pallets/multisig/src/lib.rs | 250 ++++--- pallets/multisig/src/mock.rs | 2 - pallets/multisig/src/tests.rs | 1022 +++++--------------------- pallets/multisig/src/weights.rs | 185 ++--- runtime/src/configs/mod.rs | 4 +- 7 files changed, 447 insertions(+), 1059 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index e9f26845..219748d2 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -276,12 +276,12 @@ create_multisig([alice, bob, charlie], 2) // → multisig_addr_2 (nonce=1, diffe The pallet does **not** maintain on-chain storage of executed proposal history. Instead, all historical data is available through **blockchain events**, which are designed to be efficiently indexed by off-chain indexers like **SubSquid**. -### TransactionExecuted Event +### ProposalExecuted Event -When a proposal is successfully executed, the pallet emits a comprehensive `TransactionExecuted` event containing all relevant data: +When a proposal is successfully executed, the pallet emits a comprehensive `ProposalExecuted` event containing all relevant data: ```rust -Event::TransactionExecuted { +Event::ProposalExecuted { multisig_address: T::AccountId, // The multisig that executed proposal_hash: T::Hash, // Hash of the proposal proposer: T::AccountId, // Who originally proposed it @@ -299,12 +299,14 @@ This event structure is optimized for indexing by SubSquid and similar indexers: - **Execution result**: Both successful and failed executions are recorded - **No storage bloat**: Events don't consume on-chain storage long-term -**Other events** for complete history: +**All events** for complete history: - `MultisigCreated` - When a multisig is created -- `TransactionProposed` - When a proposal is submitted -- `TransactionApproved` - Each time someone approves (includes current approval count) -- `TransactionCancelled` - When a proposal is cancelled -- `ProposalExpired` - When a proposal expires +- `ProposalCreated` - When a proposal is submitted +- `ProposalApproved` - Each time someone approves (includes current approval count) +- `ProposalExecuted` - When a proposal is executed (includes full execution details) +- `ProposalCancelled` - When a proposal is cancelled by proposer +- `ProposalRemoved` - When a proposal is removed from storage (deposits returned) +- `DepositsClaimed` - Batch removal of multiple proposals ### Benefits of Event-Based History diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 2ad1c557..d3ca9aae 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -125,7 +125,6 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - deposit: 100u32.into(), creator: caller.clone(), last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, @@ -146,6 +145,8 @@ mod benchmarks { expiry, approvals: bounded_approvals, deposit: 10u32.into(), + status: ProposalStatus::Active, + status_changed_at: frame_system::Pallet::::block_number(), }; // Match pallet hashing: hash_of(bounded_call) @@ -187,7 +188,6 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - deposit: 100u32.into(), creator: caller.clone(), last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, @@ -210,6 +210,8 @@ mod benchmarks { expiry, approvals: bounded_approvals, deposit: 10u32.into(), + status: ProposalStatus::Active, + status_changed_at: frame_system::Pallet::::block_number(), }; // Match pallet hashing: hash_of(bounded_call) @@ -220,8 +222,9 @@ mod benchmarks { #[extrinsic_call] approve(RawOrigin::Signed(signer2.clone()), multisig_address.clone(), proposal_hash); - // Verify proposal was executed and removed - assert!(!Proposals::::contains_key(&multisig_address, proposal_hash)); + // Verify proposal status was changed to Executed + let executed_proposal = Proposals::::get(&multisig_address, proposal_hash).unwrap(); + assert_eq!(executed_proposal.status, ProposalStatus::Executed); Ok(()) } @@ -250,7 +253,6 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - deposit: 100u32.into(), creator: caller.clone(), last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, @@ -271,6 +273,8 @@ mod benchmarks { expiry, approvals: bounded_approvals, deposit: 10u32.into(), + status: ProposalStatus::Active, + status_changed_at: frame_system::Pallet::::block_number(), }; // Match pallet hashing: hash_of(bounded_call) @@ -280,8 +284,9 @@ mod benchmarks { #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_hash); - // Verify proposal was cancelled and removed - assert!(!Proposals::::contains_key(&multisig_address, proposal_hash)); + // Verify proposal status was changed to Cancelled + let cancelled_proposal = Proposals::::get(&multisig_address, proposal_hash).unwrap(); + assert_eq!(cancelled_proposal.status, ProposalStatus::Cancelled); Ok(()) } @@ -310,7 +315,6 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - deposit: 100u32.into(), creator: caller.clone(), last_activity: 1u32.into(), active_proposals: 1, @@ -331,6 +335,8 @@ mod benchmarks { expiry, approvals: bounded_approvals, deposit: 10u32.into(), + status: ProposalStatus::Active, + status_changed_at: 1u32.into(), // Old timestamp to ensure it's past grace period }; // Match pallet hashing: hash_of(bounded_call) @@ -374,7 +380,6 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - deposit: 100u32.into(), creator: caller.clone(), last_activity: 1u32.into(), active_proposals: 5, @@ -397,6 +402,8 @@ mod benchmarks { expiry, approvals: bounded_approvals, deposit: 10u32.into(), + status: ProposalStatus::Active, + status_changed_at: 1u32.into(), // Old timestamp to ensure it's past grace period }; // Match pallet hashing: hash_of(bounded_call) diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 45c6a9b6..c0ef4e73 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -40,32 +40,29 @@ 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 pub nonce: u64, - /// Deposit required for storage (refundable after grace period) - pub deposit: Balance, - /// Account that created this multisig (receives deposit back) + /// Account that created this multisig pub creator: AccountId, - /// Last block when this multisig was used (for grace period calculation) + /// Last block when this multisig was used pub last_activity: BlockNumber, /// Number of currently active (non-executed/non-cancelled) proposals pub active_proposals: u32, } -impl Default - for MultisigData +impl Default + for MultisigData { fn default() -> Self { Self { signers: Default::default(), threshold: 1, nonce: 0, - deposit: Default::default(), creator: Default::default(), last_activity: Default::default(), active_proposals: 0, @@ -73,6 +70,17 @@ impl { @@ -84,8 +92,12 @@ pub struct ProposalData; - /// Deposit required per multisig account (refundable after grace period) - #[pallet::constant] - type MultisigDeposit: Get>; - - /// Fee charged for creating a multisig (non-refundable, paid always) + /// Fee charged for creating a multisig (non-refundable, burned) #[pallet::constant] type MultisigFee: Get>; @@ -176,7 +184,6 @@ pub mod pallet { /// Type alias for MultisigData with proper bounds pub type MultisigDataOf = MultisigData< - BalanceOf, BlockNumberFor, ::AccountId, BoundedSignersOf, @@ -226,24 +233,22 @@ pub mod pallet { threshold: u32, nonce: u64, }, - /// A transaction has been proposed - /// [multisig_address, proposer, proposal_hash] - TransactionProposed { + /// A proposal has been created + ProposalCreated { multisig_address: T::AccountId, proposer: T::AccountId, proposal_hash: T::Hash, }, - /// A transaction has been approved - /// [multisig_address, approver, proposal_hash, approvals_count] - TransactionApproved { + /// A proposal has been approved by a signer + ProposalApproved { multisig_address: T::AccountId, approver: T::AccountId, proposal_hash: T::Hash, approvals_count: u32, }, - /// A transaction has been executed + /// A proposal has been executed /// Contains all data needed for indexing by SubSquid - TransactionExecuted { + ProposalExecuted { multisig_address: T::AccountId, proposal_hash: T::Hash, proposer: T::AccountId, @@ -251,9 +256,8 @@ pub mod pallet { approvers: Vec, result: DispatchResult, }, - /// A transaction has been cancelled - /// [multisig_address, proposer, proposal_hash] - TransactionCancelled { + /// A proposal has been cancelled by the proposer + ProposalCancelled { multisig_address: T::AccountId, proposer: T::AccountId, proposal_hash: T::Hash, @@ -318,6 +322,8 @@ pub mod pallet { ProposalNotExpired, /// Grace period has not elapsed yet GracePeriodNotElapsed, + /// Proposal is not active (already executed or cancelled) + ProposalNotActive, } #[pallet::call] @@ -329,9 +335,7 @@ pub mod pallet { /// - `threshold`: Number of approvals required to execute transactions /// /// The multisig address is derived from a hash of all signers + global nonce. - /// The creator must pay: - /// - A fee (non-refundable, burned) - /// - A deposit (refundable after grace period of inactivity) + /// The creator must pay a non-refundable fee (burned). #[pallet::call_index(0)] #[pallet::weight(::WeightInfo::create_multisig())] pub fn create_multisig( @@ -380,10 +384,6 @@ pub mod pallet { ) .map_err(|_| Error::::InsufficientBalance)?; - // Reserve deposit from creator (will be returned after grace period) - let deposit = T::MultisigDeposit::get(); - T::Currency::reserve(&creator, deposit).map_err(|_| Error::::InsufficientBalance)?; - // Convert sorted signers to bounded vec let bounded_signers: BoundedSignersOf = sorted_signers.try_into().map_err(|_| Error::::TooManySigners)?; @@ -398,7 +398,6 @@ pub mod pallet { signers: bounded_signers.clone(), threshold, nonce, - deposit, creator: creator.clone(), last_activity: current_block, active_proposals: 0, @@ -425,8 +424,11 @@ pub mod pallet { /// - `expiry`: Block number when this proposal expires /// /// The proposer must be a signer and must pay: - /// - A deposit (returned on execute or cancel) + /// - A deposit (locked until proposal is removed after grace period) /// - A fee (non-refundable, burned immediately) + /// + /// The proposal remains in storage even after execution/cancellation. + /// Use `remove_expired()` or `claim_deposits()` after grace period to recover the deposit. #[pallet::call_index(1)] #[pallet::weight(::WeightInfo::propose())] pub fn propose( @@ -490,12 +492,16 @@ pub mod pallet { let mut approvals = BoundedApprovalsOf::::default(); let _ = approvals.try_push(proposer.clone()); + let current_block = frame_system::Pallet::::block_number(); + let proposal = ProposalData { proposer: proposer.clone(), call: bounded_call, expiry, approvals, deposit, + status: ProposalStatus::Active, + status_changed_at: current_block, }; // Store proposal @@ -509,7 +515,7 @@ pub mod pallet { }); // Emit event - Self::deposit_event(Event::TransactionProposed { + Self::deposit_event(Event::ProposalCreated { multisig_address, proposer, proposal_hash, @@ -560,7 +566,7 @@ pub mod pallet { let approvals_count = proposal.approvals.len() as u32; // Emit approval event - Self::deposit_event(Event::TransactionApproved { + Self::deposit_event(Event::ProposalApproved { multisig_address: multisig_address.clone(), approver, proposal_hash, @@ -601,17 +607,21 @@ pub mod pallet { let canceller = ensure_signed(origin)?; // Get proposal - let proposal = Proposals::::get(&multisig_address, proposal_hash) + let mut proposal = Proposals::::get(&multisig_address, proposal_hash) .ok_or(Error::::ProposalNotFound)?; // Check if caller is the proposer ensure!(canceller == proposal.proposer, Error::::NotProposer); - // Return deposit to proposer - T::Currency::unreserve(&proposal.proposer, proposal.deposit); + // Check if proposal is still active + ensure!(proposal.status == ProposalStatus::Active, Error::::ProposalNotActive); - // Remove proposal - Proposals::::remove(&multisig_address, proposal_hash); + // Mark as cancelled (deposit stays locked until removal) + proposal.status = ProposalStatus::Cancelled; + proposal.status_changed_at = frame_system::Pallet::::block_number(); + + // Update proposal in storage + Proposals::::insert(&multisig_address, proposal_hash, proposal.clone()); // Decrement active proposals counter Multisigs::::mutate(&multisig_address, |maybe_multisig| { @@ -621,7 +631,7 @@ pub mod pallet { }); // Emit event - Self::deposit_event(Event::TransactionCancelled { + Self::deposit_event(Event::ProposalCancelled { multisig_address, proposer: canceller, proposal_hash, @@ -630,13 +640,18 @@ pub mod pallet { Ok(()) } - /// Remove an expired proposal and return deposit to proposer + /// Remove a proposal and return deposit to proposer /// - /// Can be called by anyone after the proposal has expired. - /// - Within grace period: only proposer can remove, deposit returned - /// - After grace period: anyone can remove, deposit returned to proposer + /// Can be called to clean up proposals that are: + /// - Active and expired (past expiry block) + /// - Executed (status changed to Executed) + /// - Cancelled (status changed to Cancelled) /// - /// This ensures storage cleanup while giving proposers time to act. + /// Grace period protection: + /// - Within grace period: only proposer can remove + /// - After grace period: anyone can remove (deposit still returned to proposer) + /// + /// This enforces storage cleanup - users must remove old proposals to recover deposits. #[pallet::call_index(4)] #[pallet::weight(::WeightInfo::remove_expired())] pub fn remove_expired( @@ -650,18 +665,36 @@ pub mod pallet { let proposal = Proposals::::get(&multisig_address, proposal_hash) .ok_or(Error::::ProposalNotFound)?; - // Check if expired let current_block = frame_system::Pallet::::block_number(); - ensure!(current_block > proposal.expiry, Error::::ProposalNotExpired); + + // Determine if proposal can be removed + let can_remove = match proposal.status { + ProposalStatus::Active => { + // Active proposals can be removed only if expired + current_block > proposal.expiry + }, + ProposalStatus::Executed | ProposalStatus::Cancelled => { + // Executed/Cancelled proposals can always be removed (after grace period) + true + }, + }; + + ensure!(can_remove, Error::::ProposalNotExpired); // Calculate grace period end - let grace_period_end = proposal.expiry.saturating_add(T::GracePeriod::get()); + // For Active proposals: from expiry + // For Executed/Cancelled: from when status changed + let grace_period_start = match proposal.status { + ProposalStatus::Active => proposal.expiry, + ProposalStatus::Executed | ProposalStatus::Cancelled => proposal.status_changed_at, + }; + let grace_period_end = grace_period_start.saturating_add(T::GracePeriod::get()); let is_in_grace = current_block <= grace_period_end; let is_proposer = caller == proposal.proposer; // Within grace period: only proposer can remove if is_in_grace { - ensure!(is_proposer, Error::::NotProposer); + ensure!(is_proposer, Error::::GracePeriodNotElapsed); } // After grace period: anyone can remove @@ -671,12 +704,14 @@ pub mod pallet { // Remove proposal from storage Proposals::::remove(&multisig_address, proposal_hash); - // Decrement active proposals counter - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { - multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - } - }); + // Decrement active proposals counter ONLY if it was still active + if proposal.status == ProposalStatus::Active { + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); + } + }); + } // Emit event Self::deposit_event(Event::ProposalRemoved { @@ -690,17 +725,15 @@ pub mod pallet { Ok(()) } - /// Claim all deposits from cancelled and expired proposals, and inactive multisigs + /// Claim all deposits from cancelled, executed, and expired proposals /// - /// This is a batch operation that: - /// - Returns all proposal deposits where caller is proposer - /// - Returns multisig deposit if caller is creator and grace period elapsed - /// - Only works after grace period has elapsed - /// - Removes all cancelled and expired proposals from storage - /// - Removes multisig if inactive past grace period - /// - Single transaction to clean up all user's old deposits + /// This is a batch operation that removes all proposals where: + /// - Caller is the proposer + /// - Proposal is Executed, Cancelled, or Active+Expired + /// - Grace period has elapsed since status changed /// - /// Use this after grace period to recover all your deposits at once. + /// Returns all proposal deposits to the proposer in a single transaction. + /// This enforces storage cleanup - users must actively clean up to recover deposits. #[pallet::call_index(5)] #[pallet::weight(::WeightInfo::claim_deposits())] pub fn claim_deposits( @@ -724,8 +757,30 @@ pub mod pallet { return false; } + // Check if proposal can be removed + let can_remove = match proposal.status { + ProposalStatus::Active => { + // Active proposals need to be expired + current_block > proposal.expiry + }, + ProposalStatus::Executed | ProposalStatus::Cancelled => { + // Executed/Cancelled can always be removed after grace period + true + }, + }; + + if !can_remove { + return false; + } + // Calculate grace period end - let grace_period_end = proposal.expiry.saturating_add(grace_period); + // For Active: from expiry, For Executed/Cancelled: from status change + let grace_period_start = match proposal.status { + ProposalStatus::Active => proposal.expiry, + ProposalStatus::Executed | ProposalStatus::Cancelled => + proposal.status_changed_at, + }; + let grace_period_end = grace_period_start.saturating_add(grace_period); // Only process if grace period has elapsed current_block > grace_period_end @@ -742,12 +797,14 @@ pub mod pallet { Proposals::::remove(&multisig_address, hash); removed_count = removed_count.saturating_add(1); - // Decrement active proposals counter - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { - multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - } - }); + // Decrement active proposals counter ONLY if still active + if proposal.status == ProposalStatus::Active { + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); + } + }); + } // Emit event for each removed proposal Self::deposit_event(Event::ProposalRemoved { @@ -759,37 +816,13 @@ pub mod pallet { }); } - // Check if multisig itself can be removed - let mut multisig_removed = false; - if let Some(multisig_data) = Multisigs::::get(&multisig_address) { - // Calculate grace period end for multisig - let grace_period_end = multisig_data.last_activity.saturating_add(grace_period); - - // Check if grace period elapsed and no more proposals - let has_proposals = Proposals::::iter_prefix(&multisig_address).next().is_some(); - - if current_block > grace_period_end && !has_proposals { - // Check if caller is creator - if caller == multisig_data.creator { - // Return multisig deposit to creator - T::Currency::unreserve(&multisig_data.creator, multisig_data.deposit); - total_returned = total_returned.saturating_add(multisig_data.deposit); - - // Remove multisig from storage - Multisigs::::remove(&multisig_address); - - multisig_removed = true; - } - } - } - // Emit summary event Self::deposit_event(Event::DepositsClaimed { multisig_address: multisig_address.clone(), claimer: caller, total_returned, proposals_removed: removed_count, - multisig_removed, + multisig_removed: false, // Multisig is never auto-removed now }); Ok(()) @@ -830,25 +863,30 @@ pub mod pallet { /// Internal function to execute a proposal /// Called automatically from `approve()` when threshold is reached /// + /// Marks the proposal as executed. The proposal remains in storage and + /// the deposit is NOT returned immediately. Use `remove_expired()` or + /// `claim_deposits()` after grace period to remove the proposal and recover deposit. + /// /// This function is private and cannot be called from outside the pallet fn do_execute( multisig_address: T::AccountId, proposal_hash: T::Hash, - proposal: ProposalDataOf, + mut proposal: ProposalDataOf, ) -> DispatchResult { // Decode the call before modifying storage let call = ::RuntimeCall::decode(&mut &proposal.call[..]) .map_err(|_| Error::::InvalidCall)?; - // Return deposit to proposer - T::Currency::unreserve(&proposal.proposer, proposal.deposit); - // Execute the call as the multisig account let result = call.dispatch(frame_system::RawOrigin::Signed(multisig_address.clone()).into()); - // Remove proposal from active storage - Proposals::::remove(&multisig_address, proposal_hash); + // Mark as executed (deposit stays locked until removal) + proposal.status = ProposalStatus::Executed; + proposal.status_changed_at = frame_system::Pallet::::block_number(); + + // Update proposal in storage + Proposals::::insert(&multisig_address, proposal_hash, proposal.clone()); // Update multisig: decrement counter and update last_activity Multisigs::::mutate(&multisig_address, |maybe_multisig| { @@ -859,7 +897,7 @@ pub mod pallet { }); // Emit event with all execution details for SubSquid indexing - Self::deposit_event(Event::TransactionExecuted { + Self::deposit_event(Event::ProposalExecuted { multisig_address, proposal_hash, proposer: proposal.proposer, diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index f611dc41..d4a62edb 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -90,7 +90,6 @@ parameter_types! { pub const MaxSignersParam: u32 = 10; pub const MaxActiveProposalsParam: u32 = 10; // For testing pub const MaxCallSizeParam: u32 = 1024; - pub const MultisigDepositParam: Balance = 100; pub const MultisigFeeParam: Balance = 50; // Non-refundable fee pub const ProposalDepositParam: Balance = 10; pub const ProposalFeeParam: Balance = 5; // Non-refundable fee @@ -103,7 +102,6 @@ impl pallet_multisig::Config for Test { type MaxSigners = MaxSignersParam; type MaxActiveProposals = MaxActiveProposalsParam; type MaxCallSize = MaxCallSizeParam; - type MultisigDeposit = MultisigDepositParam; type MultisigFee = MultisigFeeParam; type ProposalDeposit = ProposalDepositParam; type ProposalFee = ProposalFeeParam; diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 61e1420f..df77bb68 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}; +use crate::{mock::*, Error, Event, GlobalNonce, Multisigs, ProposalStatus}; use codec::Encode; use frame_support::{assert_noop, assert_ok}; use sp_runtime::traits::Hash; @@ -31,6 +31,8 @@ fn make_call(remark: Vec) -> Vec { call.encode() } +// ==================== MULTISIG CREATION TESTS ==================== + #[test] fn create_multisig_works() { new_test_ext().execute_with(|| { @@ -44,7 +46,6 @@ fn create_multisig_works() { // Get initial balance let initial_balance = Balances::free_balance(creator); - let deposit = 100; // MultisigDepositParam let fee = 50; // MultisigFeeParam // Create multisig @@ -54,9 +55,9 @@ fn create_multisig_works() { threshold, )); - // Check that deposit was reserved and fee was burned - assert_eq!(Balances::reserved_balance(creator), deposit); - assert_eq!(Balances::free_balance(creator), initial_balance - deposit - fee); + // Check that fee was burned (no deposit anymore) + assert_eq!(Balances::reserved_balance(creator), 0); // No multisig deposit + assert_eq!(Balances::free_balance(creator), initial_balance - fee); // Check that multisig was created let global_nonce = GlobalNonce::::get(); @@ -69,9 +70,9 @@ fn create_multisig_works() { let multisig_data = Multisigs::::get(multisig_address).unwrap(); assert_eq!(multisig_data.threshold, threshold); assert_eq!(multisig_data.nonce, 0); - assert_eq!(multisig_data.deposit, deposit); assert_eq!(multisig_data.signers.to_vec(), signers); assert_eq!(multisig_data.active_proposals, 0); + assert_eq!(multisig_data.creator, creator); // Check that event was emitted System::assert_last_event( @@ -114,7 +115,7 @@ fn create_multisig_fails_with_threshold_too_high() { new_test_ext().execute_with(|| { let creator = alice(); let signers = vec![bob(), charlie()]; - let threshold = 3; // More than the number of signers + let threshold = 3; // More than number of signers assert_noop!( Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), @@ -127,7 +128,7 @@ fn create_multisig_fails_with_threshold_too_high() { fn create_multisig_fails_with_duplicate_signers() { new_test_ext().execute_with(|| { let creator = alice(); - let signers = vec![bob(), charlie(), bob()]; // Bob appears twice + let signers = vec![bob(), bob(), charlie()]; // Bob twice let threshold = 2; assert_noop!( @@ -138,54 +139,14 @@ fn create_multisig_fails_with_duplicate_signers() { } #[test] -fn create_multisig_fails_with_too_many_signers() { - new_test_ext().execute_with(|| { - let creator = alice(); - // MaxSignersParam is 10, so 11 should fail - let signers: Vec = (1..=11).collect(); - let threshold = 2; - - assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), - Error::::TooManySigners - ); - }); -} - -#[test] -fn create_multisig_fails_with_insufficient_balance() { - new_test_ext().execute_with(|| { - // Create account with insufficient balance - let poor_account = 99; - let signers = vec![bob(), charlie()]; - let threshold = 2; - - // This account has 0 balance, can't pay deposit - assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(poor_account), signers, threshold,), - Error::::InsufficientBalance - ); - }); -} - -#[test] -fn create_multiple_multisigs_works() { +fn create_multiple_multisigs_increments_nonce() { new_test_ext().execute_with(|| { - // Initialize block number for events - System::set_block_number(1); - let creator = alice(); - - // Create first multisig let signers1 = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers1.clone(), 2,)); - - // Create second multisig with different signers - let signers2 = vec![charlie(), dave()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers2.clone(), 2,)); + let signers2 = vec![bob(), dave()]; - // Check global nonce incremented - assert_eq!(GlobalNonce::::get(), 2); + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers1.clone(), 2)); + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers2.clone(), 2)); // Check both multisigs exist let multisig1 = Multisig::derive_multisig_address(&signers1, 0); @@ -193,207 +154,30 @@ fn create_multiple_multisigs_works() { assert!(Multisigs::::contains_key(multisig1)); assert!(Multisigs::::contains_key(multisig2)); - - // Charlie can be in unlimited multisigs (no artificial limit) - // Both multisigs should exist independently - }); -} - -#[test] -fn max_active_proposals_limit_works() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - // MaxActiveProposalsParam = 10 in mock - // Create 10 proposals (should work) - for i in 0..10 { - let call = make_call(vec![i as u8, 2, 3]); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call, - 1000 - )); - } - - // Check counter - let multisig_data = Multisigs::::get(multisig_address).unwrap(); - assert_eq!(multisig_data.active_proposals, 10); - - // Try to create 11th proposal (should fail) - assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, vec![99, 2, 3], 1000), - Error::::TooManyActiveProposals - ); - - // Execute one proposal to free up space - use frame_support::BoundedVec; - let call1 = make_call(vec![0, 2, 3]); - let bounded: BoundedVec::MaxCallSize> = - call1.try_into().unwrap(); - let hash1 = ::Hashing::hash_of(&bounded); - - // Approve by charlie - this will auto-execute (threshold 2 reached: bob + charlie) - assert_ok!(Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, hash1)); - - // Check counter decreased (proposal auto-executed) - let multisig_data = Multisigs::::get(multisig_address).unwrap(); - assert_eq!(multisig_data.active_proposals, 9); - - // Now we can create a new proposal - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - vec![100, 2, 3], - 1000 - )); - - // Counter back to 10 - let multisig_data = Multisigs::::get(multisig_address).unwrap(); - assert_eq!(multisig_data.active_proposals, 10); - }); -} - -#[test] -fn create_multisig_with_single_signer_works() { - new_test_ext().execute_with(|| { - let creator = alice(); - let signers = vec![bob()]; - let threshold = 1; - - assert_ok!(Multisig::create_multisig( - RuntimeOrigin::signed(creator), - signers.clone(), - threshold, - )); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - let multisig_data = Multisigs::::get(multisig_address).unwrap(); - - assert_eq!(multisig_data.threshold, 1); - assert_eq!(multisig_data.signers.len(), 1); - }); -} - -#[test] -fn is_signer_works() { - new_test_ext().execute_with(|| { - let creator = alice(); - let signers = vec![bob(), charlie(), dave()]; - let threshold = 2; - - assert_ok!(Multisig::create_multisig( - RuntimeOrigin::signed(creator), - signers.clone(), - threshold, - )); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - // Check signers - assert!(Multisig::is_signer(&multisig_address, &bob())); - assert!(Multisig::is_signer(&multisig_address, &charlie())); - assert!(Multisig::is_signer(&multisig_address, &dave())); - - // Check non-signers - assert!(!Multisig::is_signer(&multisig_address, &alice())); - assert!(!Multisig::is_signer(&multisig_address, &99)); - }); -} - -#[test] -fn derive_multisig_address_is_deterministic() { - new_test_ext().execute_with(|| { - let signers = vec![bob(), charlie(), dave()]; - let nonce = 42; - - let address1 = Multisig::derive_multisig_address(&signers, nonce); - let address2 = Multisig::derive_multisig_address(&signers, nonce); - - assert_eq!(address1, address2); - }); -} - -#[test] -fn derive_multisig_address_different_for_different_nonce() { - new_test_ext().execute_with(|| { - let signers = vec![bob(), charlie(), dave()]; - - let address1 = Multisig::derive_multisig_address(&signers, 0); - let address2 = Multisig::derive_multisig_address(&signers, 1); - - assert_ne!(address1, address2); }); } -#[test] -fn derive_multisig_address_different_for_different_signers() { - new_test_ext().execute_with(|| { - let signers1 = vec![bob(), charlie()]; - let signers2 = vec![bob(), dave()]; - let nonce = 0; - - let address1 = Multisig::derive_multisig_address(&signers1, nonce); - let address2 = Multisig::derive_multisig_address(&signers2, nonce); - - assert_ne!(address1, address2); - }); -} - -#[test] -fn signer_order_does_not_matter_for_address() { - new_test_ext().execute_with(|| { - // Signers are sorted internally, so order doesn't matter - let signers1 = vec![bob(), charlie()]; - let signers2 = vec![charlie(), bob()]; - - // Sort both to simulate what happens in create_multisig - let mut sorted1 = signers1.clone(); - let mut sorted2 = signers2.clone(); - sorted1.sort(); - sorted2.sort(); - - let nonce = 0; - let address1 = Multisig::derive_multisig_address(&sorted1, nonce); - let address2 = Multisig::derive_multisig_address(&sorted2, nonce); - - // Same signers, same nonce = same address (order doesn't matter) - assert_eq!(address1, address2); - }); -} - -// ==================== PROPOSAL TESTS ==================== +// ==================== PROPOSAL CREATION TESTS ==================== #[test] fn propose_works() { new_test_ext().execute_with(|| { System::set_block_number(1); - // Create multisig let creator = alice(); - let signers = vec![bob(), charlie(), dave()]; - let threshold = 2; - assert_ok!(Multisig::create_multisig( - RuntimeOrigin::signed(creator), - signers.clone(), - threshold - )); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); let multisig_address = Multisig::derive_multisig_address(&signers, 0); // Propose a transaction let proposer = bob(); - let call = vec![1, 2, 3, 4]; + let call = make_call(vec![1, 2, 3]); let expiry = 1000; + let initial_balance = Balances::free_balance(proposer); - let proposal_deposit = 10; // ProposalDepositParam (refundable) - let proposal_fee = 5; // ProposalFeeParam (non-refundable) + let proposal_deposit = 10; // ProposalDepositParam + let proposal_fee = 5; // ProposalFeeParam assert_ok!(Multisig::propose( RuntimeOrigin::signed(proposer), @@ -402,23 +186,24 @@ fn propose_works() { expiry )); - // Check: fee was withdrawn (lost forever) + deposit was reserved + // Check balances - deposit reserved, fee burned assert_eq!(Balances::reserved_balance(proposer), proposal_deposit); assert_eq!( Balances::free_balance(proposer), initial_balance - proposal_deposit - proposal_fee ); - // Check proposal exists + // Check event let proposal_hash = ::Hashing::hash_of(&call); - assert!(crate::Proposals::::contains_key(multisig_address, proposal_hash)); + System::assert_last_event( + Event::ProposalCreated { multisig_address, proposer, proposal_hash }.into(), + ); }); } #[test] fn propose_fails_if_not_signer() { new_test_ext().execute_with(|| { - // Create multisig let creator = alice(); let signers = vec![bob(), charlie()]; assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); @@ -426,49 +211,28 @@ fn propose_fails_if_not_signer() { let multisig_address = Multisig::derive_multisig_address(&signers, 0); // Try to propose as non-signer - let non_signer = dave(); let call = make_call(vec![1, 2, 3]); assert_noop!( - Multisig::propose(RuntimeOrigin::signed(non_signer), multisig_address, call, 1000), + Multisig::propose(RuntimeOrigin::signed(dave()), multisig_address, call, 1000), Error::::NotASigner ); }); } -#[test] -fn propose_fails_with_insufficient_balance() { - new_test_ext().execute_with(|| { - // Create multisig with poor account as signer - let creator = alice(); - let poor_account = 99; // No balance - let signers = vec![bob(), charlie(), poor_account]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - // Try to propose with insufficient balance - let call = make_call(vec![1, 2, 3]); - assert_noop!( - Multisig::propose(RuntimeOrigin::signed(poor_account), multisig_address, call, 1000), - Error::::InsufficientBalance - ); - }); -} +// ==================== APPROVAL TESTS ==================== #[test] fn approve_works() { new_test_ext().execute_with(|| { System::set_block_number(1); - // Create multisig with threshold 3 (so approve won't trigger execution) let creator = alice(); let signers = vec![bob(), charlie(), dave()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 3)); + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 3)); // Need 3 approvals let multisig_address = Multisig::derive_multisig_address(&signers, 0); - // Propose - let call = make_call(vec![1, 2, 3, 4]); + let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, @@ -478,22 +242,31 @@ fn approve_works() { let proposal_hash = ::Hashing::hash_of(&call); - // Approve (now 2/3, not executed yet) + // Charlie approves (now 2/3) assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); - // Check approval was added (proposal still exists, not executed) - let proposal = crate::Proposals::::get(multisig_address, proposal_hash).unwrap(); - assert_eq!(proposal.approvals.len(), 2); // bob + charlie - assert!(proposal.approvals.contains(&charlie())); + // Check event + System::assert_last_event( + Event::ProposalApproved { + multisig_address, + approver: charlie(), + proposal_hash, + approvals_count: 2, + } + .into(), + ); + + // Proposal should still exist (not executed yet) + assert!(crate::Proposals::::contains_key(multisig_address, proposal_hash)); }); } #[test] -fn approve_fails_if_already_approved() { +fn approve_auto_executes_when_threshold_reached() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -513,16 +286,39 @@ fn approve_fails_if_already_approved() { let proposal_hash = ::Hashing::hash_of(&call); - // Try to approve twice - assert_noop!( - Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address, proposal_hash), - Error::::AlreadyApproved + // Charlie approves - threshold reached (2/2) + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Check that proposal was executed (status changed, but still in storage) + let proposal = crate::Proposals::::get(multisig_address, proposal_hash).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Executed); + + // Deposit is still locked (not returned yet) + assert_eq!(Balances::reserved_balance(bob()), 10); // Still reserved + + // Check event was emitted + System::assert_has_event( + Event::ProposalExecuted { + multisig_address, + proposal_hash, + proposer: bob(), + call: call.clone(), + approvers: vec![bob(), charlie()], + result: Ok(()), + } + .into(), ); }); } +// ==================== CANCELLATION TESTS ==================== + #[test] -fn approve_auto_executes_when_threshold_reached() { +fn cancel_works() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -534,10 +330,6 @@ fn approve_auto_executes_when_threshold_reached() { let proposer = bob(); let call = make_call(vec![1, 2, 3]); - let initial_balance = Balances::free_balance(proposer); - let _proposal_deposit = 10; // Refundable - let proposal_fee = 5; // Non-refundable - assert_ok!(Multisig::propose( RuntimeOrigin::signed(proposer), multisig_address, @@ -547,108 +339,107 @@ fn approve_auto_executes_when_threshold_reached() { let proposal_hash = ::Hashing::hash_of(&call); - // Approve by charlie to reach threshold (bob auto-approved in propose) - // This should automatically execute the transaction - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), + // Cancel the proposal + assert_ok!(Multisig::cancel( + RuntimeOrigin::signed(proposer), multisig_address, proposal_hash )); - // Check deposit was returned, but fee was NOT returned - assert_eq!(Balances::reserved_balance(proposer), 0); - assert_eq!(Balances::free_balance(proposer), initial_balance - proposal_fee); // Only fee lost + // Proposal should still exist but marked as cancelled + let proposal = crate::Proposals::::get(multisig_address, proposal_hash).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Cancelled); + + // Deposit is still locked (not returned yet) + assert_eq!(Balances::reserved_balance(proposer), 10); - // Check proposal was removed (auto-executed) - assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); + // Check event + System::assert_last_event( + Event::ProposalCancelled { multisig_address, proposer, proposal_hash }.into(), + ); }); } #[test] -fn approve_fails_if_expired() { +fn cancel_fails_if_already_executed() { new_test_ext().execute_with(|| { System::set_block_number(1); let creator = alice(); - let signers = vec![bob(), charlie(), dave()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 3)); // Need 3 approvals + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); let multisig_address = Multisig::derive_multisig_address(&signers, 0); let call = make_call(vec![1, 2, 3]); - let expiry = 10; assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, call.clone(), - expiry + 1000 )); - // Match pallet hashing: hash_of(bounded_call) - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - let proposal_hash = get_hash(call); + let proposal_hash = ::Hashing::hash_of(&call); - // Move past expiry - System::set_block_number(expiry + 1); + // Approve to execute + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); - // Attempt to approve after expiry should fail + // Try to cancel executed proposal assert_noop!( - Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash), - Error::::ProposalExpired + Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, proposal_hash), + Error::::ProposalNotActive ); }); } +// ==================== DEPOSIT RECOVERY TESTS ==================== + #[test] -fn approve_does_not_execute_without_threshold() { +fn remove_expired_works_after_grace_period() { new_test_ext().execute_with(|| { System::set_block_number(1); let creator = alice(); - let signers = vec![bob(), charlie(), dave()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 3)); // Need 3 approvals + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); let multisig_address = Multisig::derive_multisig_address(&signers, 0); let call = make_call(vec![1, 2, 3]); + let expiry = 100; assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, call.clone(), - 1000 + expiry )); let proposal_hash = ::Hashing::hash_of(&call); - // Only 2 approvals (bob + charlie), need 3 - should NOT auto-execute - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Proposal should still exist (not executed yet) - assert!(crate::Proposals::::contains_key(multisig_address, proposal_hash)); + // Move past expiry + grace period (100 blocks) + System::set_block_number(expiry + 101); - // Now approve by dave to reach threshold - should auto-execute - assert_ok!(Multisig::approve( + // Anyone can remove after grace period + assert_ok!(Multisig::remove_expired( RuntimeOrigin::signed(dave()), multisig_address, proposal_hash )); - // Proposal should be removed (auto-executed) + // Proposal should be gone assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); + + // Deposit should be returned to proposer + assert_eq!(Balances::reserved_balance(bob()), 0); }); } #[test] -fn cancel_works() { +fn remove_expired_works_for_executed_proposal_after_grace_period() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -658,13 +449,9 @@ fn cancel_works() { let multisig_address = Multisig::derive_multisig_address(&signers, 0); - let proposer = bob(); let call = make_call(vec![1, 2, 3]); - let initial_balance = Balances::free_balance(proposer); - let proposal_fee = 5; // Non-refundable, even on cancel! - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), + RuntimeOrigin::signed(bob()), multisig_address, call.clone(), 1000 @@ -672,172 +459,25 @@ fn cancel_works() { let proposal_hash = ::Hashing::hash_of(&call); - // Cancel - assert_ok!(Multisig::cancel( - RuntimeOrigin::signed(proposer), + // Execute + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); - // Check deposit was returned, but fee was NOT returned (even on cancel!) - assert_eq!(Balances::reserved_balance(proposer), 0); - assert_eq!(Balances::free_balance(proposer), initial_balance - proposal_fee); // Fee still lost + // Move past grace period from execution + System::set_block_number(102); // 1 (execution) + 100 (grace) + 1 - // Check proposal was removed - assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); - }); -} - -#[test] -fn cancel_fails_if_not_proposer() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let call = make_call(vec![1, 2, 3]); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call.clone(), - 1000 - )); - - let proposal_hash = ::Hashing::hash_of(&call); - - // Try to cancel as different user - assert_noop!( - Multisig::cancel(RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash), - Error::::NotProposer - ); - }); -} - -#[test] -fn proposal_fee_is_never_returned() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let proposer = bob(); - let initial_balance = Balances::free_balance(proposer); - let proposal_deposit = 10; // Refundable - let proposal_fee = 5; // Non-refundable - - // Helper to get proposal hash (must convert to BoundedVec first) - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - - // Create 3 proposals - let call1 = make_call(vec![0, 2, 3]); - let call2 = make_call(vec![1, 2, 3]); - let call3 = make_call(vec![2, 2, 3]); - - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), - multisig_address, - call1.clone(), - 1000 - )); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), - multisig_address, - call2.clone(), - 1000 - )); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), - multisig_address, - call3.clone(), - 1000 - )); - - // After 3 proposals: 3 deposits reserved + 3 fees lost - assert_eq!(Balances::reserved_balance(proposer), 3 * proposal_deposit); - assert_eq!( - Balances::free_balance(proposer), - initial_balance - 3 * proposal_deposit - 3 * proposal_fee - ); - - // Cancel one proposal - let hash1 = get_hash(call1); - assert_ok!(Multisig::cancel(RuntimeOrigin::signed(proposer), multisig_address, hash1)); - - // After cancel: 2 deposits reserved + 3 fees still lost - assert_eq!(Balances::reserved_balance(proposer), 2 * proposal_deposit); - assert_eq!( - Balances::free_balance(proposer), - initial_balance - 2 * proposal_deposit - 3 * proposal_fee - ); - - // Approve another proposal (auto-executes when threshold reached) - let hash2 = get_hash(call2); - assert_ok!(Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, hash2)); - - // After auto-execute: 1 deposit reserved + 3 fees still lost - assert_eq!(Balances::reserved_balance(proposer), proposal_deposit); - assert_eq!( - Balances::free_balance(proposer), - initial_balance - proposal_deposit - 3 * proposal_fee - ); - - // Lesson: Fees are NEVER returned, regardless of outcome! - }); -} - -// ==================== EXPIRED PROPOSAL CLEANUP TESTS ==================== - -#[test] -fn remove_expired_fails_if_not_expired() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let call = make_call(vec![1, 2, 3]); - let expiry = 1000; - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), + // Remove executed proposal + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(dave()), multisig_address, - call.clone(), - expiry + proposal_hash )); - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - let proposal_hash = get_hash(call); - - // Try to remove before expiry (at block 500) - System::set_block_number(500); - assert_noop!( - Multisig::remove_expired( - RuntimeOrigin::signed(alice()), - multisig_address, - proposal_hash - ), - Error::::ProposalNotExpired - ); + // Deposit returned + assert_eq!(Balances::reserved_balance(bob()), 0); }); } @@ -852,194 +492,41 @@ fn remove_expired_within_grace_period_only_by_proposer() { let multisig_address = Multisig::derive_multisig_address(&signers, 0); - let proposer = bob(); let call = make_call(vec![1, 2, 3]); - let expiry = 1000; - let initial_balance = Balances::free_balance(proposer); - let _proposal_deposit = 10; - let proposal_fee = 5; - + let expiry = 100; assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), + RuntimeOrigin::signed(bob()), multisig_address, call.clone(), expiry )); - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - let proposal_hash = get_hash(call); + let proposal_hash = ::Hashing::hash_of(&call); - // Move to grace period (expiry + 50 < expiry + grace_period(100)) + // Move just past expiry (within grace period) System::set_block_number(expiry + 50); - // Non-proposer cannot remove within grace period + // Non-proposer cannot remove yet assert_noop!( Multisig::remove_expired( - RuntimeOrigin::signed(charlie()), + RuntimeOrigin::signed(dave()), multisig_address, proposal_hash ), - Error::::NotProposer + Error::::GracePeriodNotElapsed ); - // Proposer CAN remove within grace period - assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(proposer), - multisig_address, - proposal_hash - )); - - // Check deposit was returned, fee still lost - assert_eq!(Balances::reserved_balance(proposer), 0); - assert_eq!(Balances::free_balance(proposer), initial_balance - proposal_fee); - - // Check proposal was removed - assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); - }); -} - -#[test] -fn remove_expired_after_grace_period_by_anyone() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let proposer = bob(); - let call = make_call(vec![1, 2, 3]); - let expiry = 1000; - let initial_balance = Balances::free_balance(proposer); - let _proposal_deposit = 10; - let proposal_fee = 5; - - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), - multisig_address, - call.clone(), - expiry - )); - - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - let proposal_hash = get_hash(call); - - // Move past grace period (expiry + grace_period(100) + 1) - let grace_period = 100; // GracePeriodParam - System::set_block_number(expiry + grace_period + 1); - - // Anyone can remove after grace period (dave is not even a signer) + // Proposer can remove assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(dave()), - multisig_address, - proposal_hash - )); - - // Check deposit was returned to proposer (not dave!) - assert_eq!(Balances::reserved_balance(proposer), 0); - assert_eq!(Balances::free_balance(proposer), initial_balance - proposal_fee); - - // Check proposal was removed - assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); - }); -} - -#[test] -fn remove_expired_multiple_proposals_cleanup() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - - // Create 3 proposals with different expiries - let call1 = make_call(vec![1, 2, 3]); - let call2 = make_call(vec![4, 5, 6]); - let call3 = make_call(vec![7, 8, 9]); - - assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, - call1.clone(), - 100 // expires at 100 - )); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call2.clone(), - 200 // expires at 200 - )); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call3.clone(), - 300 // expires at 300 - )); - - let hash1 = get_hash(call1); - let hash2 = get_hash(call2); - let hash3 = get_hash(call3); - - // Move past all expiries + grace period - System::set_block_number(500); - - // Cleanup all 3 (anyone can do it) - assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(dave()), - multisig_address, - hash1 - )); - assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(dave()), - multisig_address, - hash2 - )); - assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(dave()), - multisig_address, - hash3 + proposal_hash )); - - // All removed - assert!(!crate::Proposals::::contains_key(multisig_address, hash1)); - assert!(!crate::Proposals::::contains_key(multisig_address, hash2)); - assert!(!crate::Proposals::::contains_key(multisig_address, hash3)); - - // Bob got all 3 deposits back (3 × 10 = 30) - let _proposal_deposit = 10; - let _proposal_fee = 5; - assert_eq!(Balances::reserved_balance(bob()), 0); - // Initial 1000 - (3 deposits still reserved before cleanup) - (3 fees lost) = back to - // initial - 3 fees After cleanup: initial - 3 fees }); } -// ==================== CLAIM DEPOSITS TESTS ==================== - #[test] -fn claim_deposits_removes_expired_proposals() { +fn claim_deposits_works() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -1049,225 +536,80 @@ fn claim_deposits_removes_expired_proposals() { let multisig_address = Multisig::derive_multisig_address(&signers, 0); - let proposer = bob(); - let initial_balance = Balances::free_balance(proposer); - let proposal_deposit = 10; - let proposal_fee = 5; - - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - - // Create 3 proposals with same expiry - let call1 = make_call(vec![1, 2, 3]); - let call2 = make_call(vec![4, 5, 6]); - let call3 = make_call(vec![7, 8, 9]); - let expiry = 100; - - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), - multisig_address, - call1.clone(), - expiry - )); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), - multisig_address, - call2.clone(), - expiry - )); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), - multisig_address, - call3.clone(), - expiry - )); + // Bob creates 3 proposals + for i in 0..3 { + let call = make_call(vec![i as u8; 32]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call, + 100 + )); + } - // After proposals: 3 deposits reserved, 3 fees lost - assert_eq!(Balances::reserved_balance(proposer), 3 * proposal_deposit); - assert_eq!( - Balances::free_balance(proposer), - initial_balance - 3 * proposal_deposit - 3 * proposal_fee - ); + // All reserved + assert_eq!(Balances::reserved_balance(bob()), 30); // 3 * 10 // Move past expiry + grace period - let grace_period = 100; - System::set_block_number(expiry + grace_period + 1); + System::set_block_number(201); - // Claim all deposits at once - assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(proposer), multisig_address)); + // Bob claims all deposits at once + assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); // All deposits returned - assert_eq!(Balances::reserved_balance(proposer), 0); - assert_eq!( - Balances::free_balance(proposer), - initial_balance - 3 * proposal_fee // Only fees lost - ); + assert_eq!(Balances::reserved_balance(bob()), 0); - // All proposals removed - assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call1))); - assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call2))); - assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call3))); + // Check event + System::assert_has_event( + Event::DepositsClaimed { + multisig_address, + claimer: bob(), + total_returned: 30, + proposals_removed: 3, + multisig_removed: false, + } + .into(), + ); }); } +// ==================== HELPER FUNCTION TESTS ==================== + #[test] -fn claim_deposits_only_cleans_own_proposals() { +fn derive_multisig_address_is_deterministic() { new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let expiry = 100; - - // Bob creates 2 proposals - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - vec![1, 2, 3], - expiry - )); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - vec![4, 5, 6], - expiry - )); - - // Charlie creates 1 proposal - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(charlie()), - multisig_address, - vec![7, 8, 9], - expiry - )); - - // Move past grace period - let grace_period = 100; - System::set_block_number(expiry + grace_period + 1); - - let _bob_initial = Balances::free_balance(bob()); - let _charlie_initial = Balances::free_balance(charlie()); - - // Bob claims - should only get his 2 deposits back - assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); - - // Bob: 2 deposits returned - assert_eq!(Balances::reserved_balance(bob()), 0); - - // Charlie: still has 1 deposit reserved - assert_eq!(Balances::reserved_balance(charlie()), 10); + let signers = vec![bob(), charlie(), dave()]; + let nonce = 42; - // Charlie claims his - assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(charlie()), multisig_address)); + let address1 = Multisig::derive_multisig_address(&signers, nonce); + let address2 = Multisig::derive_multisig_address(&signers, nonce); - // Charlie: deposit returned - assert_eq!(Balances::reserved_balance(charlie()), 0); + assert_eq!(address1, address2); }); } #[test] -fn claim_deposits_respects_grace_period() { +fn derive_multisig_address_different_for_different_nonce() { new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let expiry = 100; - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - vec![1, 2, 3], - expiry - )); - - // Move to within grace period - System::set_block_number(expiry + 50); // grace is 100 - - // Claim during grace - should not remove anything - assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); - - // Deposit still reserved (nothing was cleaned) - assert_eq!(Balances::reserved_balance(bob()), 10); - - // Move past grace period - System::set_block_number(expiry + 101); + let signers = vec![bob(), charlie(), dave()]; - // Claim after grace - should work - assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); + let address1 = Multisig::derive_multisig_address(&signers, 0); + let address2 = Multisig::derive_multisig_address(&signers, 1); - // Deposit returned - assert_eq!(Balances::reserved_balance(bob()), 0); + assert_ne!(address1, address2); }); } #[test] -fn claim_deposits_works_for_mixed_proposals() { +fn is_signer_works() { new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(alice()), signers.clone(), 2)); let multisig_address = Multisig::derive_multisig_address(&signers, 0); - let get_hash = |call: Vec| { - use frame_support::BoundedVec; - let bounded: BoundedVec::MaxCallSize> = - call.try_into().unwrap(); - ::Hashing::hash_of(&bounded) - }; - - // Create proposals with different expiries - let call1 = make_call(vec![1, 2, 3]); - let call2 = make_call(vec![4, 5, 6]); - let call3 = make_call(vec![7, 8, 9]); - - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call1.clone(), - 100 // expires early - )); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call2.clone(), - 500 // expires late - )); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call3.clone(), - 100 // expires early - )); - - // Move past first expiry + grace (100 + 100 = 200) - System::set_block_number(201); - - // Claim - should only clean expired proposals - assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); - - // 2 deposits returned (call1, call3 expired) - assert_eq!(Balances::reserved_balance(bob()), 10); // 1 still reserved - - // call2 still exists (not expired yet) - assert!(crate::Proposals::::contains_key(multisig_address, get_hash(call2))); - - // call1, call3 removed - assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call1))); - assert!(!crate::Proposals::::contains_key(multisig_address, get_hash(call3))); + assert!(Multisig::is_signer(&multisig_address, &bob())); + assert!(Multisig::is_signer(&multisig_address, &charlie())); + assert!(!Multisig::is_signer(&multisig_address, &dave())); }); } diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 3eb9b733..dc8690b3 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -19,22 +19,29 @@ //! Autogenerated weights for `pallet_multisig` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-01-16, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-01-19, 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` // Executed Command: -// ./target/release/quantus-node +// target/release/quantus-node // benchmark // pallet -// --chain=dev -// --pallet=pallet_multisig -// --extrinsic=* -// --steps=50 -// --repeat=20 -// --template=./.maintain/frame-weight-template.hbs -// --output=./pallets/multisig/src/weights.rs +// --chain +// dev +// --pallet +// pallet_multisig +// --extrinsic +// * +// --steps +// 50 +// --repeat +// 20 +// --output +// pallets/multisig/src/weights.rs +// --template +// .maintain/frame-weight-template.hbs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -62,92 +69,90 @@ 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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `6783` - // Minimum execution time: 189_000_000 picoseconds. - Weight::from_parts(190_000_000, 6783) + // Estimated: `6767` + // Minimum execution time: 178_000_000 picoseconds. + Weight::from_parts(180_000_000, 6767) .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) fn propose() -> Weight { // Proof Size summary in bytes: - // Measured: `477` - // Estimated: `17057` - // Minimum execution time: 151_000_000 picoseconds. - Weight::from_parts(162_000_000, 17057) + // Measured: `461` + // Estimated: `17062` + // Minimum execution time: 161_000_000 picoseconds. + Weight::from_parts(162_000_000, 17062) .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) fn approve() -> Weight { // Proof Size summary in bytes: - // Measured: `790` - // Estimated: `17057` + // Measured: `811` + // Estimated: `17062` // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(15_000_000, 17057) + Weight::from_parts(14_000_000, 17062) .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) fn approve_and_execute() -> Weight { // Proof Size summary in bytes: - // Measured: `822` - // Estimated: `17057` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(23_000_000, 17057) + // Measured: `779` + // Estimated: `17062` + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(19_000_000, 17062) .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(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `790` - // Estimated: `17057` - // Minimum execution time: 19_000_000 picoseconds. - Weight::from_parts(20_000_000, 17057) + // Measured: `779` + // Estimated: `17062` + // Minimum execution time: 12_000_000 picoseconds. + Weight::from_parts(13_000_000, 17062) .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(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `790` - // Estimated: `17057` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(20_000_000, 17057) + // Measured: `779` + // Estimated: `17062` + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(20_000_000, 17062) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:6 w:0) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Multisigs` (r:1 w:0) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1898` - // Estimated: `97392` - // Minimum execution time: 27_000_000 picoseconds. - Weight::from_parts(28_000_000, 97392) - .saturating_add(T::DbWeight::get().reads(7_u64)) + // Measured: `1654` + // Estimated: `97422` + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(22_000_000, 97422) + .saturating_add(T::DbWeight::get().reads(6_u64)) } } @@ -156,91 +161,89 @@ 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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `6783` - // Minimum execution time: 189_000_000 picoseconds. - Weight::from_parts(190_000_000, 6783) + // Estimated: `6767` + // Minimum execution time: 178_000_000 picoseconds. + Weight::from_parts(180_000_000, 6767) .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) fn propose() -> Weight { // Proof Size summary in bytes: - // Measured: `477` - // Estimated: `17057` - // Minimum execution time: 151_000_000 picoseconds. - Weight::from_parts(162_000_000, 17057) + // Measured: `461` + // Estimated: `17062` + // Minimum execution time: 161_000_000 picoseconds. + Weight::from_parts(162_000_000, 17062) .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) fn approve() -> Weight { // Proof Size summary in bytes: - // Measured: `790` - // Estimated: `17057` + // Measured: `811` + // Estimated: `17062` // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(15_000_000, 17057) + Weight::from_parts(14_000_000, 17062) .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(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) fn approve_and_execute() -> Weight { // Proof Size summary in bytes: - // Measured: `822` - // Estimated: `17057` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(23_000_000, 17057) + // Measured: `779` + // Estimated: `17062` + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(19_000_000, 17062) .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(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `790` - // Estimated: `17057` - // Minimum execution time: 19_000_000 picoseconds. - Weight::from_parts(20_000_000, 17057) + // Measured: `779` + // Estimated: `17062` + // Minimum execution time: 12_000_000 picoseconds. + Weight::from_parts(13_000_000, 17062) .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(13592), added: 16067, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `790` - // Estimated: `17057` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(20_000_000, 17057) + // Measured: `779` + // Estimated: `17062` + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(20_000_000, 17062) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:6 w:0) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13592), added: 16067, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Multisigs` (r:1 w:0) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3318), added: 5793, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1898` - // Estimated: `97392` - // Minimum execution time: 27_000_000 picoseconds. - Weight::from_parts(28_000_000, 97392) - .saturating_add(RocksDbWeight::get().reads(7_u64)) + // Measured: `1654` + // Estimated: `97422` + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(22_000_000, 97422) + .saturating_add(RocksDbWeight::get().reads(6_u64)) } } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 31358ee5..2bd0e268 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -573,9 +573,8 @@ parameter_types! { pub const MaxSigners: u32 = 100; pub const MaxActiveProposals: u32 = 100; // Max active proposals per multisig pub const MaxCallSize: u32 = 10240; // 10KB - pub const MultisigDeposit: Balance = 100 * MILLI_UNIT; // 0.1 UNIT (refundable) pub const MultisigFee: Balance = 100 * MILLI_UNIT; // 0.1 UNIT (non-refundable) - pub const ProposalDeposit: Balance = 1000 * MILLI_UNIT; // 1 UNIT (refundable) + pub const ProposalDeposit: Balance = 1000 * MILLI_UNIT; // 1 UNIT (locked until cleanup) pub const ProposalFee: Balance = 1000 * MILLI_UNIT; // 1 UNIT (non-refundable) pub const ProposalGracePeriod: BlockNumber = 28800; // ~2 days (6s blocks) } @@ -586,7 +585,6 @@ impl pallet_multisig::Config for Runtime { type MaxSigners = MaxSigners; type MaxActiveProposals = MaxActiveProposals; type MaxCallSize = MaxCallSize; - type MultisigDeposit = MultisigDeposit; type MultisigFee = MultisigFee; type ProposalDeposit = ProposalDeposit; type ProposalFee = ProposalFee; From f169177bc1b99fdca88c1726f150a4a2d64718ab Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Mon, 19 Jan 2026 16:41:26 +0800 Subject: [PATCH 09/27] feat: GracePeriod param removed --- pallets/multisig/README.md | 33 +++---- pallets/multisig/src/benchmarking.rs | 5 -- pallets/multisig/src/lib.rs | 57 +----------- pallets/multisig/src/mock.rs | 2 - pallets/multisig/src/tests.rs | 44 --------- pallets/multisig/src/weights.rs | 130 ++++++++++++++------------- runtime/src/configs/mod.rs | 2 - 7 files changed, 83 insertions(+), 190 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index 219748d2..c5b2425d 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -122,12 +122,12 @@ Removes expired proposals from storage (cleanup mechanism). **Validation:** - Proposal must exist -- Proposal must be expired (current_block > expiry) -- Within grace period (expiry < current_block ≤ expiry + GracePeriod): only proposer can remove -- After grace period (current_block > expiry + GracePeriod): anyone can remove +- For Active proposals: must be expired (current_block > expiry) +- For Executed/Cancelled proposals: can be removed anytime +- Anyone can call this function **Economic Effects:** -- ProposalDeposit returned to proposer (even if removed by someone else) +- ProposalDeposit returned to proposer - Proposal removed from storage **Economic Costs:** None (deposit always returned to proposer) @@ -140,12 +140,11 @@ Batch cleanup operation to recover all eligible deposits. **Validation:** - Only cleans proposals where caller is proposer -- Only processes proposals past grace period (current_block > expiry + GracePeriod) -- Only removes multisig if inactive (current_block > last_activity + GracePeriod) and no active proposals +- For Active proposals: must be expired (current_block > expiry) +- For Executed/Cancelled proposals: can always be removed **Economic Effects:** - Returns all eligible proposal deposits to caller -- If multisig is inactive: returns MultisigDeposit to creator and removes multisig - Removes all eligible proposals from storage **Economic Costs:** None (only returns deposits) @@ -159,14 +158,7 @@ Burned immediately upon payment, never returned: ### Deposits (Refundable) Reserved and returned under specific conditions: -- **MultisigDeposit**: 100 MILLI_UNIT - returned after grace period when multisig inactive -- **ProposalDeposit**: 1000 MILLI_UNIT - returned on execute/cancel/remove_expired - -### Grace Period -- **GracePeriod**: 28,800 blocks (~2 days with 6s blocks) -- Applies to proposals: after expiry + grace, anyone can cleanup -- Applies to multisigs: after last_activity + grace, deposit can be claimed -- Ensures proposers have time to cleanup before public cleanup +- **ProposalDeposit**: 1000 MILLI_UNIT - returned when proposal is removed (via remove_expired or claim_deposits) ### Storage Limits - **MaxSigners**: 10 - Maximum signers per multisig @@ -225,10 +217,10 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `MultisigCreated { creator, multisig_address, signers, threshold, nonce }` - `TransactionProposed { multisig_address, proposer, proposal_hash }` -- `TransactionApproved { multisig_address, approver, proposal_hash, approvals_count }` -- `TransactionExecuted { multisig_address, proposal_hash, result }` -- `TransactionCancelled { multisig_address, proposer, proposal_hash }` -- `ProposalRemoved { multisig_address, proposal_hash, proposer, removed_by, in_grace_period }` +- `ProposalApproved { multisig_address, approver, proposal_hash, approvals_count }` +- `ProposalExecuted { multisig_address, proposal_hash, proposer, call, approvers, result }` +- `ProposalCancelled { multisig_address, proposer, proposal_hash }` +- `ProposalRemoved { multisig_address, proposal_hash, proposer, removed_by }` - `DepositsClaimed { multisig_address, claimer, total_returned, proposals_removed, multisig_removed }` ## Errors @@ -251,7 +243,7 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `InsufficientBalance` - Not enough funds for fee/deposit - `TooManyActiveProposals` - Multisig has MaxActiveProposals open proposals - `ProposalNotExpired` - Proposal not yet expired (for remove_expired) -- `GracePeriodNotElapsed` - Grace period not yet passed +- `ProposalNotActive` - Proposal is not active (already executed or cancelled) ## Important Behavior @@ -353,7 +345,6 @@ impl pallet_multisig::Config for Runtime { type MultisigFee = ConstU128<{ 100 * MILLI_UNIT }>; type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; - type GracePeriod = ConstU32<28800>; // ~2 days type PalletId = ConstPalletId(*b"py/mltsg"); type WeightInfo = pallet_multisig::weights::SubstrateWeight; } diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index d3ca9aae..a50f39d4 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -146,7 +146,6 @@ mod benchmarks { approvals: bounded_approvals, deposit: 10u32.into(), status: ProposalStatus::Active, - status_changed_at: frame_system::Pallet::::block_number(), }; // Match pallet hashing: hash_of(bounded_call) @@ -211,7 +210,6 @@ mod benchmarks { approvals: bounded_approvals, deposit: 10u32.into(), status: ProposalStatus::Active, - status_changed_at: frame_system::Pallet::::block_number(), }; // Match pallet hashing: hash_of(bounded_call) @@ -274,7 +272,6 @@ mod benchmarks { approvals: bounded_approvals, deposit: 10u32.into(), status: ProposalStatus::Active, - status_changed_at: frame_system::Pallet::::block_number(), }; // Match pallet hashing: hash_of(bounded_call) @@ -336,7 +333,6 @@ mod benchmarks { approvals: bounded_approvals, deposit: 10u32.into(), status: ProposalStatus::Active, - status_changed_at: 1u32.into(), // Old timestamp to ensure it's past grace period }; // Match pallet hashing: hash_of(bounded_call) @@ -403,7 +399,6 @@ mod benchmarks { approvals: bounded_approvals, deposit: 10u32.into(), status: ProposalStatus::Active, - status_changed_at: 1u32.into(), // Old timestamp to ensure it's past grace period }; // Match pallet hashing: hash_of(bounded_call) diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index c0ef4e73..a1a1f5ca 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -96,8 +96,6 @@ pub struct ProposalData>; - /// Grace period after expiry when proposer can still recover deposit - /// After this period, anyone can remove the proposal and deposit is returned to proposer - #[pallet::constant] - type GracePeriod: Get>; - /// Pallet ID for generating multisig addresses #[pallet::constant] type PalletId: Get; @@ -268,7 +261,6 @@ pub mod pallet { proposal_hash: T::Hash, proposer: T::AccountId, removed_by: T::AccountId, - in_grace_period: bool, }, /// Batch deposits claimed DepositsClaimed { @@ -320,8 +312,6 @@ pub mod pallet { ProposalHasDeposit, /// Proposal has not expired yet ProposalNotExpired, - /// Grace period has not elapsed yet - GracePeriodNotElapsed, /// Proposal is not active (already executed or cancelled) ProposalNotActive, } @@ -492,8 +482,6 @@ pub mod pallet { let mut approvals = BoundedApprovalsOf::::default(); let _ = approvals.try_push(proposer.clone()); - let current_block = frame_system::Pallet::::block_number(); - let proposal = ProposalData { proposer: proposer.clone(), call: bounded_call, @@ -501,7 +489,6 @@ pub mod pallet { approvals, deposit, status: ProposalStatus::Active, - status_changed_at: current_block, }; // Store proposal @@ -618,7 +605,6 @@ pub mod pallet { // Mark as cancelled (deposit stays locked until removal) proposal.status = ProposalStatus::Cancelled; - proposal.status_changed_at = frame_system::Pallet::::block_number(); // Update proposal in storage Proposals::::insert(&multisig_address, proposal_hash, proposal.clone()); @@ -674,30 +660,13 @@ pub mod pallet { current_block > proposal.expiry }, ProposalStatus::Executed | ProposalStatus::Cancelled => { - // Executed/Cancelled proposals can always be removed (after grace period) + // Executed/Cancelled proposals can always be removed true }, }; ensure!(can_remove, Error::::ProposalNotExpired); - // Calculate grace period end - // For Active proposals: from expiry - // For Executed/Cancelled: from when status changed - let grace_period_start = match proposal.status { - ProposalStatus::Active => proposal.expiry, - ProposalStatus::Executed | ProposalStatus::Cancelled => proposal.status_changed_at, - }; - let grace_period_end = grace_period_start.saturating_add(T::GracePeriod::get()); - let is_in_grace = current_block <= grace_period_end; - let is_proposer = caller == proposal.proposer; - - // Within grace period: only proposer can remove - if is_in_grace { - ensure!(is_proposer, Error::::GracePeriodNotElapsed); - } - // After grace period: anyone can remove - // Return deposit to proposer T::Currency::unreserve(&proposal.proposer, proposal.deposit); @@ -719,7 +688,6 @@ pub mod pallet { proposal_hash, proposer: proposal.proposer.clone(), removed_by: caller, - in_grace_period: is_in_grace, }); Ok(()) @@ -743,7 +711,6 @@ pub mod pallet { let caller = ensure_signed(origin)?; let current_block = frame_system::Pallet::::block_number(); - let grace_period = T::GracePeriod::get(); let mut total_returned = BalanceOf::::zero(); let mut removed_count = 0u32; @@ -758,32 +725,16 @@ pub mod pallet { } // Check if proposal can be removed - let can_remove = match proposal.status { + match proposal.status { ProposalStatus::Active => { // Active proposals need to be expired current_block > proposal.expiry }, ProposalStatus::Executed | ProposalStatus::Cancelled => { - // Executed/Cancelled can always be removed after grace period + // Executed/Cancelled can always be removed true }, - }; - - if !can_remove { - return false; } - - // Calculate grace period end - // For Active: from expiry, For Executed/Cancelled: from status change - let grace_period_start = match proposal.status { - ProposalStatus::Active => proposal.expiry, - ProposalStatus::Executed | ProposalStatus::Cancelled => - proposal.status_changed_at, - }; - let grace_period_end = grace_period_start.saturating_add(grace_period); - - // Only process if grace period has elapsed - current_block > grace_period_end }) .collect(); @@ -812,7 +763,6 @@ pub mod pallet { proposal_hash: hash, proposer: caller.clone(), removed_by: caller.clone(), - in_grace_period: false, }); } @@ -883,7 +833,6 @@ pub mod pallet { // Mark as executed (deposit stays locked until removal) proposal.status = ProposalStatus::Executed; - proposal.status_changed_at = frame_system::Pallet::::block_number(); // Update proposal in storage Proposals::::insert(&multisig_address, proposal_hash, proposal.clone()); diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index d4a62edb..bc872e41 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -93,7 +93,6 @@ parameter_types! { pub const MultisigFeeParam: Balance = 50; // Non-refundable fee pub const ProposalDepositParam: Balance = 10; pub const ProposalFeeParam: Balance = 5; // Non-refundable fee - pub const GracePeriodParam: u64 = 100; // 100 blocks for testing } impl pallet_multisig::Config for Test { @@ -105,7 +104,6 @@ impl pallet_multisig::Config for Test { type MultisigFee = MultisigFeeParam; type ProposalDeposit = ProposalDepositParam; type ProposalFee = ProposalFeeParam; - type GracePeriod = GracePeriodParam; type PalletId = MultisigPalletId; type WeightInfo = (); } diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index df77bb68..27086fda 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -481,50 +481,6 @@ fn remove_expired_works_for_executed_proposal_after_grace_period() { }); } -#[test] -fn remove_expired_within_grace_period_only_by_proposer() { - new_test_ext().execute_with(|| { - System::set_block_number(1); - - let creator = alice(); - let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - - let call = make_call(vec![1, 2, 3]); - let expiry = 100; - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - call.clone(), - expiry - )); - - let proposal_hash = ::Hashing::hash_of(&call); - - // Move just past expiry (within grace period) - System::set_block_number(expiry + 50); - - // Non-proposer cannot remove yet - assert_noop!( - Multisig::remove_expired( - RuntimeOrigin::signed(dave()), - multisig_address, - proposal_hash - ), - Error::::GracePeriodNotElapsed - ); - - // Proposer can remove - assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(bob()), - multisig_address, - proposal_hash - )); - }); -} - #[test] fn claim_deposits_works() { new_test_ext().execute_with(|| { diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index dc8690b3..768dfd9c 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -74,85 +74,88 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `6767` - // Minimum execution time: 178_000_000 picoseconds. - Weight::from_parts(180_000_000, 6767) + // Minimum execution time: 181_000_000 picoseconds. + Weight::from_parts(193_000_000, 6767) .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(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn propose() -> Weight { // Proof Size summary in bytes: // Measured: `461` - // Estimated: `17062` - // Minimum execution time: 161_000_000 picoseconds. - Weight::from_parts(162_000_000, 17062) + // Estimated: `17058` + // Minimum execution time: 162_000_000 picoseconds. + Weight::from_parts(168_000_000, 17058) .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(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn approve() -> Weight { // Proof Size summary in bytes: - // Measured: `811` - // Estimated: `17062` - // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(14_000_000, 17062) + // Measured: `807` + // Estimated: `17058` + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(17_000_000, 17058) .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(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn approve_and_execute() -> Weight { // Proof Size summary in bytes: - // Measured: `779` - // Estimated: `17062` + // Measured: `775` + // Estimated: `17058` // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(19_000_000, 17062) + Weight::from_parts(20_000_000, 17058) .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(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `779` - // Estimated: `17062` + // Measured: `775` + // Estimated: `17058` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(13_000_000, 17062) + Weight::from_parts(14_000_000, 17058) .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(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `779` - // Estimated: `17062` - // Minimum execution time: 19_000_000 picoseconds. - Weight::from_parts(20_000_000, 17062) + // Measured: `775` + // Estimated: `17058` + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_000_000, 17058) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } - /// Storage: `Multisig::Proposals` (r:6 w:0) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:6 w:5) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1654` - // Estimated: `97422` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(22_000_000, 97422) - .saturating_add(T::DbWeight::get().reads(6_u64)) + // Measured: `1887` + // Estimated: `97398` + // Minimum execution time: 79_000_000 picoseconds. + Weight::from_parts(81_000_000, 97398) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) } } @@ -166,84 +169,87 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `6767` - // Minimum execution time: 178_000_000 picoseconds. - Weight::from_parts(180_000_000, 6767) + // Minimum execution time: 181_000_000 picoseconds. + Weight::from_parts(193_000_000, 6767) .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(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn propose() -> Weight { // Proof Size summary in bytes: // Measured: `461` - // Estimated: `17062` - // Minimum execution time: 161_000_000 picoseconds. - Weight::from_parts(162_000_000, 17062) + // Estimated: `17058` + // Minimum execution time: 162_000_000 picoseconds. + Weight::from_parts(168_000_000, 17058) .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(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn approve() -> Weight { // Proof Size summary in bytes: - // Measured: `811` - // Estimated: `17062` - // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(14_000_000, 17062) + // Measured: `807` + // Estimated: `17058` + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(17_000_000, 17058) .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(3302), added: 5777, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn approve_and_execute() -> Weight { // Proof Size summary in bytes: - // Measured: `779` - // Estimated: `17062` + // Measured: `775` + // Estimated: `17058` // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(19_000_000, 17062) + Weight::from_parts(20_000_000, 17058) .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(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `779` - // Estimated: `17062` + // Measured: `775` + // Estimated: `17058` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(13_000_000, 17062) + Weight::from_parts(14_000_000, 17058) .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(13597), added: 16072, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `779` - // Estimated: `17062` - // Minimum execution time: 19_000_000 picoseconds. - Weight::from_parts(20_000_000, 17062) + // Measured: `775` + // Estimated: `17058` + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_000_000, 17058) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } - /// Storage: `Multisig::Proposals` (r:6 w:0) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13597), added: 16072, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:6 w:5) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1654` - // Estimated: `97422` - // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(22_000_000, 97422) - .saturating_add(RocksDbWeight::get().reads(6_u64)) + // Measured: `1887` + // Estimated: `97398` + // Minimum execution time: 79_000_000 picoseconds. + Weight::from_parts(81_000_000, 97398) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(6_u64)) } } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 2bd0e268..5b4c13e4 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -576,7 +576,6 @@ parameter_types! { pub const MultisigFee: Balance = 100 * MILLI_UNIT; // 0.1 UNIT (non-refundable) pub const ProposalDeposit: Balance = 1000 * MILLI_UNIT; // 1 UNIT (locked until cleanup) pub const ProposalFee: Balance = 1000 * MILLI_UNIT; // 1 UNIT (non-refundable) - pub const ProposalGracePeriod: BlockNumber = 28800; // ~2 days (6s blocks) } impl pallet_multisig::Config for Runtime { @@ -588,7 +587,6 @@ impl pallet_multisig::Config for Runtime { type MultisigFee = MultisigFee; type ProposalDeposit = ProposalDeposit; type ProposalFee = ProposalFee; - type GracePeriod = ProposalGracePeriod; type PalletId = MultisigPalletId; type WeightInfo = pallet_multisig::weights::SubstrateWeight; } From 2a5b74d58b3d82daa9fc10d89491c64c0e8e87c7 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 20 Jan 2026 06:11:04 +0800 Subject: [PATCH 10/27] fix: Reentrancy --- pallets/multisig/src/lib.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index a1a1f5ca..61058b8a 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -815,29 +815,29 @@ pub mod pallet { /// /// Marks the proposal as executed. The proposal remains in storage and /// the deposit is NOT returned immediately. Use `remove_expired()` or - /// `claim_deposits()` after grace period to remove the proposal and recover deposit. + /// `claim_deposits()` to remove the proposal and recover deposit. /// /// This function is private and cannot be called from outside the pallet + /// + /// SECURITY: Uses Checks-Effects-Interactions pattern to prevent reentrancy attacks. + /// Storage is updated BEFORE dispatching the call. fn do_execute( multisig_address: T::AccountId, proposal_hash: T::Hash, mut proposal: ProposalDataOf, ) -> DispatchResult { - // Decode the call before modifying storage + // CHECKS: Decode the call (validation) let call = ::RuntimeCall::decode(&mut &proposal.call[..]) .map_err(|_| Error::::InvalidCall)?; - // Execute the call as the multisig account - let result = - call.dispatch(frame_system::RawOrigin::Signed(multisig_address.clone()).into()); - - // Mark as executed (deposit stays locked until removal) + // EFFECTS: Mark as executed (deposit stays locked until removal) + // This MUST happen before call.dispatch() to prevent reentrancy proposal.status = ProposalStatus::Executed; - // Update proposal in storage + // EFFECTS: Update proposal in storage BEFORE external interaction Proposals::::insert(&multisig_address, proposal_hash, proposal.clone()); - // Update multisig: decrement counter and update last_activity + // EFFECTS: Update multisig counters BEFORE external interaction Multisigs::::mutate(&multisig_address, |maybe_multisig| { if let Some(multisig) = maybe_multisig { multisig.last_activity = frame_system::Pallet::::block_number(); @@ -845,6 +845,11 @@ pub mod pallet { } }); + // INTERACTIONS: NOW execute the call as the multisig account + // Even if this call tries to re-enter, the proposal is already marked as Executed + let result = + call.dispatch(frame_system::RawOrigin::Signed(multisig_address.clone()).into()); + // Emit event with all execution details for SubSquid indexing Self::deposit_event(Event::ProposalExecuted { multisig_address, From 187035720f0654dcf32100b5788576673ac762af Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 20 Jan 2026 08:51:06 +0800 Subject: [PATCH 11/27] feat: History cleaning redesigned --- pallets/multisig/README.md | 10 +- pallets/multisig/src/lib.rs | 16 ++ pallets/multisig/src/mock.rs | 2 + pallets/multisig/src/tests.rs | 296 ++++++++++++++++++++++++++++++++++ runtime/src/configs/mod.rs | 2 + 5 files changed, 322 insertions(+), 4 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index c5b2425d..2c61ef37 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -162,7 +162,8 @@ Reserved and returned under specific conditions: ### Storage Limits - **MaxSigners**: 10 - Maximum signers per multisig -- **MaxActiveProposals**: 100 - Maximum open proposals per multisig at once +- **MaxActiveProposals**: 100 - Maximum active (open) proposals per multisig at once +- **MaxTotalProposalsInStorage**: 200 - Maximum total proposals in storage (Active + Executed + Cancelled). This prevents unbounded storage growth and incentivizes cleanup - **MaxCallSize**: 1024 bytes - Maximum encoded call size ## Storage @@ -242,6 +243,7 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `InvalidCall` - Call decoding failed during execution - `InsufficientBalance` - Not enough funds for fee/deposit - `TooManyActiveProposals` - Multisig has MaxActiveProposals open proposals +- `TooManyProposalsInStorage` - Multisig has MaxTotalProposalsInStorage total proposals (cleanup required to create new) - `ProposalNotExpired` - Proposal not yet expired (for remove_expired) - `ProposalNotActive` - Proposal is not active (already executed or cancelled) @@ -338,10 +340,10 @@ This event structure is optimized for indexing by SubSquid and similar indexers: impl pallet_multisig::Config for Runtime { type RuntimeCall = RuntimeCall; type Currency = Balances; - type MaxSigners = ConstU32<10>; + type MaxSigners = ConstU32<100>; type MaxActiveProposals = ConstU32<100>; - type MaxCallSize = ConstU32<1024>; - type MultisigDeposit = ConstU128<{ 100 * MILLI_UNIT }>; + type MaxTotalProposalsInStorage = ConstU32<200>; + type MaxCallSize = ConstU32<10240>; type MultisigFee = ConstU128<{ 100 * MILLI_UNIT }>; type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 61058b8a..cd2a6bb3 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -140,6 +140,11 @@ pub mod pallet { #[pallet::constant] type MaxActiveProposals: Get; + /// Maximum total number of proposals in storage per multisig (Active + Executed + Cancelled) + /// This prevents unbounded storage growth and incentivizes cleanup + #[pallet::constant] + type MaxTotalProposalsInStorage: Get; + /// Maximum size of an encoded call #[pallet::constant] type MaxCallSize: Get; @@ -306,6 +311,8 @@ pub mod pallet { InvalidCall, /// Too many active proposals for this multisig TooManyActiveProposals, + /// Too many total proposals in storage for this multisig (cleanup required) + TooManyProposalsInStorage, /// Insufficient balance for deposit InsufficientBalance, /// Proposal has active deposit @@ -440,6 +447,15 @@ pub mod pallet { Error::::TooManyActiveProposals ); + // Check total proposals in storage limit (Active + Executed + Cancelled) + // This incentivizes cleanup and prevents unbounded storage growth + let total_proposals_in_storage = + Proposals::::iter_prefix(&multisig_address).count() as u32; + ensure!( + total_proposals_in_storage < T::MaxTotalProposalsInStorage::get(), + Error::::TooManyProposalsInStorage + ); + // Check call size ensure!(call.len() as u32 <= T::MaxCallSize::get(), Error::::CallTooLarge); diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index bc872e41..f362c8f6 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -89,6 +89,7 @@ parameter_types! { pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); pub const MaxSignersParam: u32 = 10; pub const MaxActiveProposalsParam: u32 = 10; // For testing + pub const MaxTotalProposalsInStorageParam: u32 = 20; // 2x MaxActiveProposals pub const MaxCallSizeParam: u32 = 1024; pub const MultisigFeeParam: Balance = 50; // Non-refundable fee pub const ProposalDepositParam: Balance = 10; @@ -100,6 +101,7 @@ impl pallet_multisig::Config for Test { type Currency = Balances; type MaxSigners = MaxSignersParam; type MaxActiveProposals = MaxActiveProposalsParam; + type MaxTotalProposalsInStorage = MaxTotalProposalsInStorageParam; type MaxCallSize = MaxCallSizeParam; type MultisigFee = MultisigFeeParam; type ProposalDeposit = ProposalDepositParam; diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 27086fda..3e7dde56 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -569,3 +569,299 @@ fn is_signer_works() { assert!(!Multisig::is_signer(&multisig_address, &dave())); }); } + +#[test] +fn too_many_proposals_in_storage_fails() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // MaxActiveProposalsParam = 10, MaxTotalProposalsInStorageParam = 20 + // Strategy: Keep active < 10, but total = 20 + // Create cycles: propose -> execute/cancel to keep active low but total high + + // Cycle 1: Create 10, execute all 10 (active=0, total=10 executed) + for i in 0..10 { + let call = make_call(vec![i as u8]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call, + 1000 + )); + // Immediately execute to keep active low + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + } + + // Cycle 2: Create 9 more (active=9, total=19) + for i in 10..19 { + let call = make_call(vec![i as u8]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call, + 2000 + )); + } + + // Now: 9 Active, 10 Executed = 19 total in storage + // One more to reach limit + let call = make_call(vec![19]); + assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 2000)); + + // Now: 10 Active, 10 Executed = 20 total (AT LIMIT) + // Try to create 21st proposal - should fail with TooManyProposalsInStorage + // Active check: 10 < 10 = false, but let's execute one first + let call = make_call(vec![10]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Now: 9 Active, 11 Executed = 20 total + // Active check will pass (9 < 10), but total check will fail + let call = make_call(vec![99]); + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 3000), + Error::::TooManyProposalsInStorage + ); + }); +} + +#[test] +fn total_proposals_counts_executed_and_cancelled() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Create 10 active proposals + for i in 0..10 { + let call = make_call(vec![i as u8]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call, + 1000 + )); + } + + // Execute 5 of them (they become Executed status, still in storage) + for i in 0..5 { + let call = make_call(vec![i as u8]); + let proposal_hash = ::Hashing::hash_of(&call); + // Auto-execute by reaching threshold + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + } + + // Cancel 3 more (they become Cancelled status, still in storage) + for i in 5..8 { + let call = make_call(vec![i as u8]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::cancel( + RuntimeOrigin::signed(bob()), + multisig_address, + proposal_hash + )); + } + + // Now we have: 2 Active + 5 Executed + 3 Cancelled = 10 total + // MaxActiveProposals = 10, MaxTotalProposalsInStorage = 20 + // We can add 8 more active (to reach 10 active) and 10 more total (to reach 20 total) + + // Add 8 more active proposals - should work (2+8=10 active, 10+8=18 total) + for i in 20..28 { + let call = make_call(vec![i as u8]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call, + 2000 + )); + } + + // Execute one to make room for active (now 9 active, 19 total) + let call = make_call(vec![8]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Add one more (10 active, 20 total = AT LIMIT) + let call = make_call(vec![30]); + assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 2000)); + + // Now: 10 Active (9,20-28) + 6 Executed (0-4,8) + 3 Cancelled (5-7) = 19 total + // Execute one more to free up active but keep total at 19 + let call = make_call(vec![9]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Now: 9 Active (20-28) + 7 Executed (0-4,8,9) + 3 Cancelled (5-7) = 19 total + // Add one more to reach 20 total + let call = make_call(vec![31]); + assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 3000)); + + // Now: 10 Active (20-28,31) + 7 Executed + 3 Cancelled = 20 total + // Execute one to make room for active check + let call = make_call(vec![20]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Now: 9 Active (21-28,31) + 8 Executed + 3 Cancelled = 20 total + // Active check will pass (9 < 10), but total check will fail + let call = make_call(vec![99]); + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 4000), + Error::::TooManyProposalsInStorage + ); + }); +} + +#[test] +fn cleanup_allows_new_proposals() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Create 10 proposals + for i in 0..10 { + let call = make_call(vec![i as u8]); + let expiry = 100; // All expire at block 100 + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call, + expiry + )); + } + + // Execute first 5 to make room (no longer active, but still in storage) + for i in 0..5 { + let call = make_call(vec![i as u8]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + } + + // Move past expiry for the remaining 5 + System::set_block_number(101); + + // Now: 5 Active(expired) + 5 Executed = 10 total + // Create 10 more proposals (cycling execute to keep active low) + for i in 10..20 { + let call = make_call(vec![i as u8]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call, + 200 + )); + // Execute immediately if i < 15 to keep active count low + if i < 15 { + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + } + } + + // Now: 5 Active(expired) + 5 Active(fresh) + 10 Executed = 20 total + // Active check: 10 < 10 = false, let's execute one + let call = make_call(vec![15]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Now: 5 Active(expired) + 4 Active(fresh) + 11 Executed = 20 total + // Active: 9 < 10 ✓, Total: 20 = 20 ✗ + let call = make_call(vec![99]); + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 200), + Error::::TooManyProposalsInStorage + ); + + // Cleanup the 5 expired ones + for i in 5..10 { + let call = make_call(vec![i as u8]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(bob()), + multisig_address, + proposal_hash + )); + } + + // Now: 4 Active + 11 Executed = 15 total. Can add 5 more! + for i in 20..25 { + let call = make_call(vec![i as u8]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call, + 300 + )); + } + + // Now: 9 Active + 11 Executed = 20 total (AT LIMIT) + // Execute one more to make room for active check + let call = make_call(vec![20]); + let proposal_hash = ::Hashing::hash_of(&call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + + // Now: 8 Active + 12 Executed = 20 total + // Active: 8 < 10 ✓, Total: 20 = 20 ✗ + let call = make_call(vec![98]); + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 300), + Error::::TooManyProposalsInStorage + ); + }); +} diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 5b4c13e4..505d8097 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -572,6 +572,7 @@ parameter_types! { pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); pub const MaxSigners: u32 = 100; pub const MaxActiveProposals: u32 = 100; // Max active proposals per multisig + pub const MaxTotalProposalsInStorage: u32 = 200; // Max total in storage (Active + Executed + Cancelled) pub const MaxCallSize: u32 = 10240; // 10KB pub const MultisigFee: Balance = 100 * MILLI_UNIT; // 0.1 UNIT (non-refundable) pub const ProposalDeposit: Balance = 1000 * MILLI_UNIT; // 1 UNIT (locked until cleanup) @@ -583,6 +584,7 @@ impl pallet_multisig::Config for Runtime { type Currency = Balances; type MaxSigners = MaxSigners; type MaxActiveProposals = MaxActiveProposals; + type MaxTotalProposalsInStorage = MaxTotalProposalsInStorage; type MaxCallSize = MaxCallSize; type MultisigFee = MultisigFee; type ProposalDeposit = ProposalDeposit; From a6e2b5e7419aafa5c34d0f61315bd5c9dd072cdc Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 20 Jan 2026 09:04:35 +0800 Subject: [PATCH 12/27] fix: Expiry - additional validation --- pallets/multisig/README.md | 6 ++++-- pallets/multisig/src/lib.rs | 12 +++++++++--- pallets/multisig/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index 2c61ef37..ca8766cb 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -62,8 +62,9 @@ Creates a new proposal for multisig execution. **Validation:** - Caller must be a signer - Call size must be ≤ MaxCallSize -- Multisig cannot have more than MaxActiveProposals open proposals -- Expiry must be in the future (current_block < expiry) +- Multisig cannot have MaxActiveProposals or more open proposals +- Multisig cannot have MaxTotalProposalsInStorage or more total proposals in storage +- Expiry must be in the future (expiry > current_block) **Economic Costs:** - **ProposalFee**: 1000 MILLI_UNIT (non-refundable, burned immediately) @@ -238,6 +239,7 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `NotProposer` - Caller is not the proposer (for cancel) - `AlreadyApproved` - Signer already approved this proposal - `NotEnoughApprovals` - Threshold not met (internal error, should not occur) +- `ExpiryInPast` - Proposal expiry is not in the future (for propose) - `ProposalExpired` - Proposal deadline passed (for approve) - `CallTooLarge` - Encoded call exceeds MaxCallSize - `InvalidCall` - Call decoding failed during execution diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index cd2a6bb3..5bdfd0e9 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -140,8 +140,8 @@ pub mod pallet { #[pallet::constant] type MaxActiveProposals: Get; - /// Maximum total number of proposals in storage per multisig (Active + Executed + Cancelled) - /// This prevents unbounded storage growth and incentivizes cleanup + /// Maximum total number of proposals in storage per multisig (Active + Executed + + /// Cancelled) This prevents unbounded storage growth and incentivizes cleanup #[pallet::constant] type MaxTotalProposalsInStorage: Get; @@ -303,6 +303,8 @@ pub mod pallet { AlreadyApproved, /// Not enough approvals to execute NotEnoughApprovals, + /// Proposal expiry is in the past + ExpiryInPast, /// Proposal has expired ProposalExpired, /// Call data too large @@ -459,6 +461,10 @@ pub mod pallet { // Check call size ensure!(call.len() as u32 <= T::MaxCallSize::get(), Error::::CallTooLarge); + // Validate expiry is in the future + let current_block = frame_system::Pallet::::block_number(); + ensure!(expiry > current_block, Error::::ExpiryInPast); + // Charge non-refundable fee (burned immediately) let fee = T::ProposalFee::get(); let _ = T::Currency::withdraw( @@ -477,7 +483,7 @@ pub mod pallet { // Update multisig last_activity Multisigs::::mutate(&multisig_address, |maybe_multisig| { if let Some(multisig) = maybe_multisig { - multisig.last_activity = frame_system::Pallet::::block_number(); + multisig.last_activity = current_block; } }); diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 3e7dde56..4826c787 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -865,3 +865,33 @@ fn cleanup_allows_new_proposals() { ); }); } + +#[test] +fn propose_fails_with_expiry_in_past() { + new_test_ext().execute_with(|| { + System::set_block_number(100); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + + // Try to create proposal with expiry in the past (< current_block) + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call.clone(), 50), + Error::::ExpiryInPast + ); + + // Try with expiry equal to current block (not > current_block) + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call.clone(), 100), + Error::::ExpiryInPast + ); + + // Valid: expiry in the future + assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 101)); + }); +} From f1dbc30b9515a911be2b496cd04ae169babebe26 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 20 Jan 2026 09:48:34 +0800 Subject: [PATCH 13/27] feat: Proposal nonce --- pallets/multisig/src/benchmarking.rs | 5 ++ pallets/multisig/src/lib.rs | 30 ++++++- pallets/multisig/src/tests.rs | 67 +++++++++++----- pallets/multisig/src/weights.rs | 114 +++++++++++++-------------- 4 files changed, 134 insertions(+), 82 deletions(-) diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index a50f39d4..f66eb9f8 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -125,6 +125,7 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, + proposal_nonce: 0, creator: caller.clone(), last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, @@ -187,6 +188,7 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, + proposal_nonce: 0, creator: caller.clone(), last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, @@ -251,6 +253,7 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, + proposal_nonce: 0, creator: caller.clone(), last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, @@ -312,6 +315,7 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, + proposal_nonce: 0, creator: caller.clone(), last_activity: 1u32.into(), active_proposals: 1, @@ -376,6 +380,7 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, + proposal_nonce: 0, creator: caller.clone(), last_activity: 1u32.into(), active_proposals: 5, diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 5bdfd0e9..f8abd90a 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -45,8 +45,10 @@ pub struct MultisigData { pub signers: BoundedSigners, /// Number of approvals required to execute a transaction pub threshold: u32, - /// Global unique identifier for this multisig + /// Global unique identifier for this multisig (for address derivation) pub nonce: u64, + /// Proposal counter for unique proposal hashes + pub proposal_nonce: u32, /// Account that created this multisig pub creator: AccountId, /// Last block when this multisig was used @@ -63,6 +65,7 @@ impl Default signers: Default::default(), threshold: 1, nonce: 0, + proposal_nonce: 0, creator: Default::default(), last_activity: Default::default(), active_proposals: 0, @@ -397,6 +400,7 @@ pub mod pallet { signers: bounded_signers.clone(), threshold, nonce, + proposal_nonce: 0, creator: creator.clone(), last_activity: current_block, active_proposals: 0, @@ -491,10 +495,22 @@ pub mod pallet { let bounded_call: BoundedCallOf = call.try_into().map_err(|_| Error::::CallTooLarge)?; - // Calculate proposal hash - let proposal_hash = T::Hashing::hash_of(&bounded_call); + // Get and increment proposal nonce for unique hash + let proposal_nonce = Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + let nonce = multisig.proposal_nonce; + multisig.proposal_nonce = multisig.proposal_nonce.saturating_add(1); + nonce + } else { + 0 // Should never happen due to earlier check + } + }); - // Check if proposal already exists + // Calculate proposal hash including nonce for uniqueness + // This allows multiple proposals with the same call but different nonces + let proposal_hash = T::Hashing::hash_of(&(&bounded_call, proposal_nonce)); + + // Check if proposal already exists (should be impossible with nonce, but safety check) ensure!( !Proposals::::contains_key(&multisig_address, proposal_hash), Error::::ProposalHasDeposit @@ -802,6 +818,12 @@ pub mod pallet { } impl Pallet { + /// Calculate proposal hash from call and proposal nonce + /// This ensures each proposal has a unique hash even if the call is identical + pub fn calculate_proposal_hash(call: &[u8], proposal_nonce: u32) -> T::Hash { + T::Hashing::hash_of(&(call, proposal_nonce)) + } + /// 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. diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 4826c787..18445f8d 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -31,6 +31,27 @@ fn make_call(remark: Vec) -> Vec { call.encode() } +/// Helper function to calculate proposal hash for testing +/// Note: This calculates hash for the LAST proposal (uses current proposal_nonce - 1) +/// because propose() increments nonce before calculating hash +fn calculate_last_proposal_hash( + multisig_address: u64, + call: &[u8], +) -> ::Hash { + let multisig = Multisigs::::get(multisig_address).expect("Multisig should exist"); + // The last proposal used (proposal_nonce - 1) because propose() increments it + let nonce_used = multisig.proposal_nonce.saturating_sub(1); + Multisig::calculate_proposal_hash(call, nonce_used) +} + +/// Helper function to calculate proposal hash for a specific nonce +fn calculate_proposal_hash_with_nonce( + call: &[u8], + nonce: u32, +) -> ::Hash { + Multisig::calculate_proposal_hash(call, nonce) +} + // ==================== MULTISIG CREATION TESTS ==================== #[test] @@ -194,7 +215,7 @@ fn propose_works() { ); // Check event - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); System::assert_last_event( Event::ProposalCreated { multisig_address, proposer, proposal_hash }.into(), ); @@ -240,7 +261,7 @@ fn approve_works() { 1000 )); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); // Charlie approves (now 2/3) assert_ok!(Multisig::approve( @@ -284,7 +305,7 @@ fn approve_auto_executes_when_threshold_reached() { 1000 )); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); // Charlie approves - threshold reached (2/2) assert_ok!(Multisig::approve( @@ -337,7 +358,7 @@ fn cancel_works() { 1000 )); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); // Cancel the proposal assert_ok!(Multisig::cancel( @@ -379,7 +400,7 @@ fn cancel_fails_if_already_executed() { 1000 )); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); // Approve to execute assert_ok!(Multisig::approve( @@ -418,7 +439,7 @@ fn remove_expired_works_after_grace_period() { expiry )); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); // Move past expiry + grace period (100 blocks) System::set_block_number(expiry + 101); @@ -457,7 +478,7 @@ fn remove_expired_works_for_executed_proposal_after_grace_period() { 1000 )); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); // Execute assert_ok!(Multisig::approve( @@ -588,13 +609,14 @@ fn too_many_proposals_in_storage_fails() { // Cycle 1: Create 10, execute all 10 (active=0, total=10 executed) for i in 0..10 { let call = make_call(vec![i as u8]); - let proposal_hash = ::Hashing::hash_of(&call); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, - call, + call.clone(), 1000 )); + // Calculate hash after propose (uses incremented nonce) + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); // Immediately execute to keep active low assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), @@ -623,7 +645,7 @@ fn too_many_proposals_in_storage_fails() { // Try to create 21st proposal - should fail with TooManyProposalsInStorage // Active check: 10 < 10 = false, but let's execute one first let call = make_call(vec![10]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, 10); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, @@ -665,7 +687,7 @@ fn total_proposals_counts_executed_and_cancelled() { // Execute 5 of them (they become Executed status, still in storage) for i in 0..5 { let call = make_call(vec![i as u8]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, i); // Auto-execute by reaching threshold assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), @@ -677,7 +699,7 @@ fn total_proposals_counts_executed_and_cancelled() { // Cancel 3 more (they become Cancelled status, still in storage) for i in 5..8 { let call = make_call(vec![i as u8]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, i); assert_ok!(Multisig::cancel( RuntimeOrigin::signed(bob()), multisig_address, @@ -702,7 +724,7 @@ fn total_proposals_counts_executed_and_cancelled() { // Execute one to make room for active (now 9 active, 19 total) let call = make_call(vec![8]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, 8); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, @@ -716,7 +738,7 @@ fn total_proposals_counts_executed_and_cancelled() { // Now: 10 Active (9,20-28) + 6 Executed (0-4,8) + 3 Cancelled (5-7) = 19 total // Execute one more to free up active but keep total at 19 let call = make_call(vec![9]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, 9); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, @@ -731,7 +753,7 @@ fn total_proposals_counts_executed_and_cancelled() { // Now: 10 Active (20-28,31) + 7 Executed + 3 Cancelled = 20 total // Execute one to make room for active check let call = make_call(vec![20]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, 10); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, @@ -774,7 +796,7 @@ fn cleanup_allows_new_proposals() { // Execute first 5 to make room (no longer active, but still in storage) for i in 0..5 { let call = make_call(vec![i as u8]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, i); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, @@ -789,13 +811,14 @@ fn cleanup_allows_new_proposals() { // Create 10 more proposals (cycling execute to keep active low) for i in 10..20 { let call = make_call(vec![i as u8]); - let proposal_hash = ::Hashing::hash_of(&call); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, - call, + call.clone(), 200 )); + // Calculate hash after propose + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); // Execute immediately if i < 15 to keep active count low if i < 15 { assert_ok!(Multisig::approve( @@ -808,8 +831,9 @@ fn cleanup_allows_new_proposals() { // Now: 5 Active(expired) + 5 Active(fresh) + 10 Executed = 20 total // Active check: 10 < 10 = false, let's execute one + // vec![15] was created in for i in 10..20, it was 6th iteration (nonce=15) let call = make_call(vec![15]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, 15); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, @@ -827,7 +851,7 @@ fn cleanup_allows_new_proposals() { // Cleanup the 5 expired ones for i in 5..10 { let call = make_call(vec![i as u8]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, i); assert_ok!(Multisig::remove_expired( RuntimeOrigin::signed(bob()), multisig_address, @@ -848,8 +872,9 @@ fn cleanup_allows_new_proposals() { // Now: 9 Active + 11 Executed = 20 total (AT LIMIT) // Execute one more to make room for active check + // vec![20] was created in for i in 20..25, first iteration (nonce=20) let call = make_call(vec![20]); - let proposal_hash = ::Hashing::hash_of(&call); + let proposal_hash = calculate_proposal_hash_with_nonce(&call, 20); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 768dfd9c..48e38948 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-19, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-01-20, 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` @@ -69,88 +69,88 @@ 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(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `6767` - // Minimum execution time: 181_000_000 picoseconds. - Weight::from_parts(193_000_000, 6767) + // Estimated: `6771` + // Minimum execution time: 180_000_000 picoseconds. + Weight::from_parts(183_000_000, 6771) .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(3302), added: 5777, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:2 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn propose() -> Weight { // Proof Size summary in bytes: - // Measured: `461` - // Estimated: `17058` - // Minimum execution time: 162_000_000 picoseconds. - Weight::from_parts(168_000_000, 17058) - .saturating_add(T::DbWeight::get().reads(2_u64)) + // Measured: `521` + // Estimated: `33126` + // Minimum execution time: 163_000_000 picoseconds. + Weight::from_parts(166_000_000, 33126) + .saturating_add(T::DbWeight::get().reads(3_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(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn approve() -> Weight { // Proof Size summary in bytes: - // Measured: `807` + // Measured: `811` // Estimated: `17058` - // Minimum execution time: 15_000_000 picoseconds. - Weight::from_parts(17_000_000, 17058) + // Minimum execution time: 14_000_000 picoseconds. + Weight::from_parts(15_000_000, 17058) .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(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn approve_and_execute() -> Weight { // Proof Size summary in bytes: - // Measured: `775` + // Measured: `779` // Estimated: `17058` - // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(20_000_000, 17058) + // Minimum execution time: 17_000_000 picoseconds. + Weight::from_parts(18_000_000, 17058) .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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `775` + // Measured: `779` // Estimated: `17058` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(14_000_000, 17058) + Weight::from_parts(12_000_000, 17058) .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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `775` + // Measured: `779` // Estimated: `17058` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17058) + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(20_000_000, 17058) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:6 w:5) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1887` + // Measured: `1891` // Estimated: `97398` // Minimum execution time: 79_000_000 picoseconds. Weight::from_parts(81_000_000, 97398) @@ -164,88 +164,88 @@ 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(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `6767` - // Minimum execution time: 181_000_000 picoseconds. - Weight::from_parts(193_000_000, 6767) + // Estimated: `6771` + // Minimum execution time: 180_000_000 picoseconds. + Weight::from_parts(183_000_000, 6771) .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(3302), added: 5777, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:2 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn propose() -> Weight { // Proof Size summary in bytes: - // Measured: `461` - // Estimated: `17058` - // Minimum execution time: 162_000_000 picoseconds. - Weight::from_parts(168_000_000, 17058) - .saturating_add(RocksDbWeight::get().reads(2_u64)) + // Measured: `521` + // Estimated: `33126` + // Minimum execution time: 163_000_000 picoseconds. + Weight::from_parts(166_000_000, 33126) + .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn approve() -> Weight { // Proof Size summary in bytes: - // Measured: `807` + // Measured: `811` // Estimated: `17058` - // Minimum execution time: 15_000_000 picoseconds. - Weight::from_parts(17_000_000, 17058) + // Minimum execution time: 14_000_000 picoseconds. + Weight::from_parts(15_000_000, 17058) .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(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn approve_and_execute() -> Weight { // Proof Size summary in bytes: - // Measured: `775` + // Measured: `779` // Estimated: `17058` - // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(20_000_000, 17058) + // Minimum execution time: 17_000_000 picoseconds. + Weight::from_parts(18_000_000, 17058) .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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) fn cancel() -> Weight { // Proof Size summary in bytes: - // Measured: `775` + // Measured: `779` // Estimated: `17058` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(14_000_000, 17058) + Weight::from_parts(12_000_000, 17058) .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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `775` + // Measured: `779` // Estimated: `17058` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17058) + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(20_000_000, 17058) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Proposals` (r:6 w:5) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3302), added: 5777, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1887` + // Measured: `1891` // Estimated: `97398` // Minimum execution time: 79_000_000 picoseconds. Weight::from_parts(81_000_000, 97398) From 07613cd1d191e0c201e9247c7bb04b3bbfda0128 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 20 Jan 2026 11:14:25 +0800 Subject: [PATCH 14/27] feat: Dynamic weights --- pallets/multisig/src/benchmarking.rs | 30 ++++++--- pallets/multisig/src/lib.rs | 6 +- pallets/multisig/src/tests.rs | 2 - pallets/multisig/src/weights.rs | 96 +++++++++++++++++----------- 4 files changed, 84 insertions(+), 50 deletions(-) diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index f66eb9f8..6263bb24 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -59,7 +59,9 @@ mod benchmarks { } #[benchmark] - fn propose() -> Result<(), BenchmarkError> { + fn propose( + c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, + ) -> Result<(), BenchmarkError> { // Setup: Create a multisig first let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(10000u128)); @@ -83,8 +85,9 @@ mod benchmarks { sorted_signers.sort(); let multisig_address = Multisig::::derive_multisig_address(&sorted_signers, 0); - // Create a simple call - let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; + // Create a remark call where the remark itself is c bytes + // We limit c to MaxCallSize - 100 to account for encoding overhead + 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(); @@ -99,7 +102,9 @@ mod benchmarks { } #[benchmark] - fn approve() -> Result<(), BenchmarkError> { + fn approve( + c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, + ) -> Result<(), BenchmarkError> { // 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(); @@ -133,7 +138,8 @@ mod benchmarks { Multisigs::::insert(&multisig_address, multisig_data); // Directly insert proposal into storage with 1 approval - let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; + // 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 expiry = frame_system::Pallet::::block_number() + 1000u32.into(); @@ -165,7 +171,9 @@ mod benchmarks { } #[benchmark] - fn approve_and_execute() -> Result<(), BenchmarkError> { + 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)); @@ -197,7 +205,8 @@ mod benchmarks { // Directly insert proposal with 1 approval (caller already approved) // signer2 will approve and trigger execution - let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; + // 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 expiry = frame_system::Pallet::::block_number() + 1000u32.into(); @@ -230,7 +239,9 @@ mod benchmarks { } #[benchmark] - fn cancel() -> Result<(), BenchmarkError> { + fn cancel( + c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, + ) -> Result<(), BenchmarkError> { // Setup: Create multisig and proposal directly in storage let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(10000u128)); @@ -261,7 +272,8 @@ mod benchmarks { Multisigs::::insert(&multisig_address, multisig_data); // Directly insert proposal into storage - let system_call = frame_system::Call::::remark { remark: vec![1u8; 32] }; + // 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 expiry = frame_system::Pallet::::block_number() + 1000u32.into(); diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index f8abd90a..61cd36a7 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -433,7 +433,7 @@ pub mod pallet { /// The proposal remains in storage even after execution/cancellation. /// Use `remove_expired()` or `claim_deposits()` after grace period to recover the deposit. #[pallet::call_index(1)] - #[pallet::weight(::WeightInfo::propose())] + #[pallet::weight(::WeightInfo::propose(call.len() as u32))] pub fn propose( origin: OriginFor, multisig_address: T::AccountId, @@ -558,7 +558,7 @@ pub mod pallet { /// - `multisig_address`: The multisig account /// - `proposal_hash`: Hash of the proposal to approve #[pallet::call_index(2)] - #[pallet::weight(::WeightInfo::approve())] + #[pallet::weight(::WeightInfo::approve(T::MaxCallSize::get()))] pub fn approve( origin: OriginFor, multisig_address: T::AccountId, @@ -623,7 +623,7 @@ pub mod pallet { /// - `multisig_address`: The multisig account /// - `proposal_hash`: Hash of the proposal to cancel #[pallet::call_index(3)] - #[pallet::weight(::WeightInfo::cancel())] + #[pallet::weight(::WeightInfo::cancel(T::MaxCallSize::get()))] pub fn cancel( origin: OriginFor, multisig_address: T::AccountId, diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 18445f8d..2d5e22da 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -3,8 +3,6 @@ use crate::{mock::*, Error, Event, GlobalNonce, Multisigs, ProposalStatus}; use codec::Encode; use frame_support::{assert_noop, assert_ok}; -use sp_runtime::traits::Hash; - /// Helper function to get Alice's account ID fn alice() -> u64 { 1 diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 48e38948..a61633ae 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -55,10 +55,10 @@ use core::marker::PhantomData; /// Weight functions needed for `pallet_multisig`. pub trait WeightInfo { fn create_multisig() -> Weight; - fn propose() -> Weight; - fn approve() -> Weight; - fn approve_and_execute() -> Weight; - fn cancel() -> Weight; + fn propose(c: u32, ) -> Weight; + fn approve(c: u32, ) -> Weight; + fn approve_and_execute(c: u32, ) -> Weight; + fn cancel(c: u32, ) -> Weight; fn remove_expired() -> Weight; fn claim_deposits() -> Weight; } @@ -74,8 +74,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `6771` - // Minimum execution time: 180_000_000 picoseconds. - Weight::from_parts(183_000_000, 6771) + // Minimum execution time: 178_000_000 picoseconds. + Weight::from_parts(181_000_000, 6771) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -83,12 +83,15 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:2 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - fn propose() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `521` // Estimated: `33126` // Minimum execution time: 163_000_000 picoseconds. - Weight::from_parts(166_000_000, 33126) + Weight::from_parts(63_843_863, 33126) + // Standard Error: 361 + .saturating_add(Weight::from_parts(165_427, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -96,12 +99,15 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - fn approve() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn approve(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `811` + // Measured: `781 + c * (1 ±0)` // Estimated: `17058` // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(15_000_000, 17058) + Weight::from_parts(14_289_646, 17058) + // Standard Error: 6 + .saturating_add(Weight::from_parts(383, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -109,12 +115,15 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - fn approve_and_execute() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn approve_and_execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `779` + // Measured: `749 + c * (1 ±0)` // Estimated: `17058` // Minimum execution time: 17_000_000 picoseconds. - Weight::from_parts(18_000_000, 17058) + Weight::from_parts(17_839_063, 17058) + // Standard Error: 6 + .saturating_add(Weight::from_parts(769, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -122,12 +131,15 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) - fn cancel() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `779` + // Measured: `749 + c * (1 ±0)` // Estimated: `17058` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(12_000_000, 17058) + Weight::from_parts(12_401_943, 17058) + // Standard Error: 6 + .saturating_add(Weight::from_parts(527, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -139,7 +151,7 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `779` // Estimated: `17058` - // Minimum execution time: 19_000_000 picoseconds. + // Minimum execution time: 20_000_000 picoseconds. Weight::from_parts(20_000_000, 17058) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -152,8 +164,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1891` // Estimated: `97398` - // Minimum execution time: 79_000_000 picoseconds. - Weight::from_parts(81_000_000, 97398) + // Minimum execution time: 80_000_000 picoseconds. + Weight::from_parts(82_000_000, 97398) .saturating_add(T::DbWeight::get().reads(7_u64)) .saturating_add(T::DbWeight::get().writes(6_u64)) } @@ -169,8 +181,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `6771` - // Minimum execution time: 180_000_000 picoseconds. - Weight::from_parts(183_000_000, 6771) + // Minimum execution time: 178_000_000 picoseconds. + Weight::from_parts(181_000_000, 6771) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -178,12 +190,15 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:2 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - fn propose() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `521` // Estimated: `33126` // Minimum execution time: 163_000_000 picoseconds. - Weight::from_parts(166_000_000, 33126) + Weight::from_parts(63_843_863, 33126) + // Standard Error: 361 + .saturating_add(Weight::from_parts(165_427, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -191,12 +206,15 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - fn approve() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn approve(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `811` + // Measured: `781 + c * (1 ±0)` // Estimated: `17058` // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(15_000_000, 17058) + Weight::from_parts(14_289_646, 17058) + // Standard Error: 6 + .saturating_add(Weight::from_parts(383, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -204,12 +222,15 @@ impl WeightInfo for () { /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - fn approve_and_execute() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn approve_and_execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `779` + // Measured: `749 + c * (1 ±0)` // Estimated: `17058` // Minimum execution time: 17_000_000 picoseconds. - Weight::from_parts(18_000_000, 17058) + Weight::from_parts(17_839_063, 17058) + // Standard Error: 6 + .saturating_add(Weight::from_parts(769, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -217,12 +238,15 @@ impl WeightInfo for () { /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) - fn cancel() -> Weight { + /// The range of component `c` is `[0, 10140]`. + fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `779` + // Measured: `749 + c * (1 ±0)` // Estimated: `17058` // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(12_000_000, 17058) + Weight::from_parts(12_401_943, 17058) + // Standard Error: 6 + .saturating_add(Weight::from_parts(527, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -234,7 +258,7 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `779` // Estimated: `17058` - // Minimum execution time: 19_000_000 picoseconds. + // Minimum execution time: 20_000_000 picoseconds. Weight::from_parts(20_000_000, 17058) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -247,8 +271,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1891` // Estimated: `97398` - // Minimum execution time: 79_000_000 picoseconds. - Weight::from_parts(81_000_000, 97398) + // Minimum execution time: 80_000_000 picoseconds. + Weight::from_parts(82_000_000, 97398) .saturating_add(RocksDbWeight::get().reads(7_u64)) .saturating_add(RocksDbWeight::get().writes(6_u64)) } From 398f5af9bfef3493071d4434e1e69241722375a0 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 20 Jan 2026 12:29:45 +0800 Subject: [PATCH 15/27] feat: Multisig deposit fee --- pallets/multisig/src/lib.rs | 25 ++++++++++++++++-- pallets/multisig/src/mock.rs | 20 +++++++------- pallets/multisig/src/tests.rs | 49 ++++++++++++++++++++++++++++++----- runtime/src/configs/mod.rs | 2 ++ 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 61cd36a7..61445583 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -118,7 +118,10 @@ pub mod pallet { }; use frame_system::pallet_prelude::*; use sp_arithmetic::traits::Saturating; - use sp_runtime::traits::{Dispatchable, Hash, TrailingZeroInput}; + use sp_runtime::{ + traits::{Dispatchable, Hash, TrailingZeroInput}, + Permill, + }; #[pallet::pallet] pub struct Pallet(_); @@ -164,6 +167,13 @@ pub mod pallet { #[pallet::constant] type ProposalFee: Get>; + /// Percentage increase in ProposalFee for each signer in the multisig. + /// + /// Formula: `FinalFee = ProposalFee + (ProposalFee * SignerCount * SignerStepFactor)` + /// Example: If Fee=100, Signers=5, Factor=1%, then Extra = 100 * 5 * 0.01 = 5. Total = 105. + #[pallet::constant] + type SignerStepFactor: Get; + /// Pallet ID for generating multisig addresses #[pallet::constant] type PalletId: Get; @@ -469,8 +479,19 @@ pub mod pallet { let current_block = frame_system::Pallet::::block_number(); ensure!(expiry > current_block, Error::::ExpiryInPast); + // Calculate dynamic fee based on number of signers + // Fee = Base + (Base * SignerCount * StepFactor) + let base_fee = T::ProposalFee::get(); + let signers_count = multisig_data.signers.len() as u32; + let step_factor = T::SignerStepFactor::get(); + + // Calculate extra fee: (Base * Factor) * Count + // mul_floor returns the part of the fee corresponding to the percentage + let fee_increase_per_signer = step_factor.mul_floor(base_fee); + let total_increase = fee_increase_per_signer.saturating_mul(signers_count.into()); + let fee = base_fee.saturating_add(total_increase); + // Charge non-refundable fee (burned immediately) - let fee = T::ProposalFee::get(); let _ = T::Currency::withdraw( &proposer, fee, diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index f362c8f6..22d7ebde 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -9,7 +9,7 @@ use frame_support::{ use sp_core::H256; use sp_runtime::{ traits::{BlakeTwo256, IdentityLookup}, - BuildStorage, + BuildStorage, Permill, }; type Block = frame_system::mocking::MockBlock; @@ -91,9 +91,10 @@ parameter_types! { pub const MaxActiveProposalsParam: u32 = 10; // For testing pub const MaxTotalProposalsInStorageParam: u32 = 20; // 2x MaxActiveProposals pub const MaxCallSizeParam: u32 = 1024; - pub const MultisigFeeParam: Balance = 50; // Non-refundable fee - pub const ProposalDepositParam: Balance = 10; - pub const ProposalFeeParam: Balance = 5; // Non-refundable fee + pub const MultisigFeeParam: Balance = 1000; // Non-refundable fee + pub const ProposalDepositParam: Balance = 100; + pub const ProposalFeeParam: Balance = 1000; // Non-refundable fee + pub const SignerStepFactorParam: Permill = Permill::from_parts(10_000); // 1% } impl pallet_multisig::Config for Test { @@ -106,6 +107,7 @@ impl pallet_multisig::Config for Test { type MultisigFee = MultisigFeeParam; type ProposalDeposit = ProposalDepositParam; type ProposalFee = ProposalFeeParam; + type SignerStepFactor = SignerStepFactorParam; type PalletId = MultisigPalletId; type WeightInfo = (); } @@ -116,11 +118,11 @@ pub fn new_test_ext() -> sp_io::TestExternalities { pallet_balances::GenesisConfig:: { balances: vec![ - (1, 1000), // Alice - (2, 2000), // Bob - (3, 3000), // Charlie - (4, 4000), // Dave - (5, 5000), // Eve + (1, 100000), // Alice + (2, 200000), // Bob + (3, 300000), // Charlie + (4, 400000), // Dave + (5, 500000), // Eve ], } .assimilate_storage(&mut t) diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 2d5e22da..26fefe7e 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -65,7 +65,7 @@ fn create_multisig_works() { // Get initial balance let initial_balance = Balances::free_balance(creator); - let fee = 50; // MultisigFeeParam + let fee = 1000; // MultisigFeeParam // Create multisig assert_ok!(Multisig::create_multisig( @@ -195,8 +195,9 @@ fn propose_works() { let expiry = 1000; let initial_balance = Balances::free_balance(proposer); - let proposal_deposit = 10; // ProposalDepositParam - let proposal_fee = 5; // ProposalFeeParam + let proposal_deposit = 100; // ProposalDepositParam (Changed in mock) + // Fee calculation: Base(1000) + (Base(1000) * 1% * 2 signers) = 1000 + 20 = 1020 + let proposal_fee = 1020; assert_ok!(Multisig::propose( RuntimeOrigin::signed(proposer), @@ -317,7 +318,7 @@ fn approve_auto_executes_when_threshold_reached() { assert_eq!(proposal.status, ProposalStatus::Executed); // Deposit is still locked (not returned yet) - assert_eq!(Balances::reserved_balance(bob()), 10); // Still reserved + assert_eq!(Balances::reserved_balance(bob()), 100); // Still reserved // Check event was emitted System::assert_has_event( @@ -370,7 +371,7 @@ fn cancel_works() { assert_eq!(proposal.status, ProposalStatus::Cancelled); // Deposit is still locked (not returned yet) - assert_eq!(Balances::reserved_balance(proposer), 10); + assert_eq!(Balances::reserved_balance(proposer), 100); // Check event System::assert_last_event( @@ -523,7 +524,7 @@ fn claim_deposits_works() { } // All reserved - assert_eq!(Balances::reserved_balance(bob()), 30); // 3 * 10 + assert_eq!(Balances::reserved_balance(bob()), 300); // 3 * 100 // Move past expiry + grace period System::set_block_number(201); @@ -539,7 +540,7 @@ fn claim_deposits_works() { Event::DepositsClaimed { multisig_address, claimer: bob(), - total_returned: 30, + total_returned: 300, proposals_removed: 3, multisig_removed: false, } @@ -918,3 +919,37 @@ fn propose_fails_with_expiry_in_past() { assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 101)); }); } + +#[test] +fn propose_charges_correct_fee_with_signer_factor() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + // 3 Signers: Bob, Charlie, Dave + let signers = vec![bob(), charlie(), dave()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let proposer = bob(); + let call = make_call(vec![1, 2, 3]); + let initial_balance = Balances::free_balance(proposer); + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer), + multisig_address, + call, + 1000 + )); + + // ProposalFeeParam = 1000 + // SignerStepFactor = 1% + // Signers = 3 + // Calculation: 1000 + (1000 * 1% * 3) = 1000 + 30 = 1030 + let expected_fee = 1030; + let deposit = 100; // ProposalDepositParam + + assert_eq!(Balances::free_balance(proposer), initial_balance - deposit - expected_fee); + }); +} diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 505d8097..66947b36 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -577,6 +577,7 @@ parameter_types! { pub const MultisigFee: Balance = 100 * MILLI_UNIT; // 0.1 UNIT (non-refundable) pub const ProposalDeposit: Balance = 1000 * MILLI_UNIT; // 1 UNIT (locked until cleanup) pub const ProposalFee: Balance = 1000 * MILLI_UNIT; // 1 UNIT (non-refundable) + pub const SignerStepFactorParam: Permill = Permill::from_percent(1); } impl pallet_multisig::Config for Runtime { @@ -589,6 +590,7 @@ impl pallet_multisig::Config for Runtime { type MultisigFee = MultisigFee; type ProposalDeposit = ProposalDeposit; type ProposalFee = ProposalFee; + type SignerStepFactor = SignerStepFactorParam; type PalletId = MultisigPalletId; type WeightInfo = pallet_multisig::weights::SubstrateWeight; } From 918c6686f790fc1e452efd95842b2bcb7781a323 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 20 Jan 2026 13:53:46 +0800 Subject: [PATCH 16/27] feat: MaxExpiry param --- pallets/multisig/README.md | 5 + pallets/multisig/src/benchmarking.rs | 5 + pallets/multisig/src/lib.rs | 96 ++++++++++++++- pallets/multisig/src/mock.rs | 4 + pallets/multisig/src/tests.rs | 101 +++++++++++++++- pallets/multisig/src/weights.rs | 169 ++++++++++++++++----------- runtime/src/configs/mod.rs | 4 + 7 files changed, 307 insertions(+), 77 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index ca8766cb..fce7eb64 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -65,6 +65,7 @@ Creates a new proposal for multisig execution. - Multisig cannot have MaxActiveProposals or more open proposals - Multisig cannot have MaxTotalProposalsInStorage or more total proposals in storage - Expiry must be in the future (expiry > current_block) +- Expiry must not exceed MaxExpiryDuration blocks from now (expiry ≤ current_block + MaxExpiryDuration) **Economic Costs:** - **ProposalFee**: 1000 MILLI_UNIT (non-refundable, burned immediately) @@ -166,6 +167,7 @@ Reserved and returned under specific conditions: - **MaxActiveProposals**: 100 - Maximum active (open) proposals per multisig at once - **MaxTotalProposalsInStorage**: 200 - Maximum total proposals in storage (Active + Executed + Cancelled). This prevents unbounded storage growth and incentivizes cleanup - **MaxCallSize**: 1024 bytes - Maximum encoded call size +- **MaxExpiryDuration**: Maximum blocks in the future that a proposal can expire (e.g., 100,000 blocks ≈ 2 weeks at 12s blocks). Prevents locking deposits for extremely long periods ## Storage @@ -240,6 +242,7 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `AlreadyApproved` - Signer already approved this proposal - `NotEnoughApprovals` - Threshold not met (internal error, should not occur) - `ExpiryInPast` - Proposal expiry is not in the future (for propose) +- `ExpiryTooFar` - Proposal expiry exceeds MaxExpiryDuration (for propose) - `ProposalExpired` - Proposal deadline passed (for approve) - `CallTooLarge` - Encoded call exceeds MaxCallSize - `InvalidCall` - Call decoding failed during execution @@ -349,6 +352,8 @@ impl pallet_multisig::Config for Runtime { type MultisigFee = ConstU128<{ 100 * MILLI_UNIT }>; type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; + type SignerStepFactor = Permill::from_percent(1); + type MaxExpiryDuration = ConstU32<100_800>; // ~2 weeks at 12s blocks type PalletId = ConstPalletId(*b"py/mltsg"); type WeightInfo = pallet_multisig::weights::SubstrateWeight; } diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 6263bb24..12c1fb88 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -132,6 +132,7 @@ mod benchmarks { nonce: 0, proposal_nonce: 0, creator: caller.clone(), + deposit: T::MultisigDeposit::get(), last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, }; @@ -198,6 +199,7 @@ mod benchmarks { nonce: 0, proposal_nonce: 0, creator: caller.clone(), + deposit: T::MultisigDeposit::get(), last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, }; @@ -266,6 +268,7 @@ mod benchmarks { nonce: 0, proposal_nonce: 0, creator: caller.clone(), + deposit: T::MultisigDeposit::get(), last_activity: frame_system::Pallet::::block_number(), active_proposals: 1, }; @@ -329,6 +332,7 @@ mod benchmarks { nonce: 0, proposal_nonce: 0, creator: caller.clone(), + deposit: T::MultisigDeposit::get(), last_activity: 1u32.into(), active_proposals: 1, }; @@ -394,6 +398,7 @@ mod benchmarks { nonce: 0, proposal_nonce: 0, creator: caller.clone(), + deposit: T::MultisigDeposit::get(), last_activity: 1u32.into(), active_proposals: 5, }; diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 61445583..65a44090 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -40,7 +40,7 @@ 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 @@ -51,14 +51,16 @@ pub struct MultisigData { pub proposal_nonce: u32, /// Account that created this multisig pub creator: AccountId, + /// Deposit reserved by the creator + pub deposit: Balance, /// Last block when this multisig was used pub last_activity: BlockNumber, /// Number of currently active (non-executed/non-cancelled) proposals pub active_proposals: u32, } -impl Default - for MultisigData +impl Default + for MultisigData { fn default() -> Self { Self { @@ -67,6 +69,7 @@ impl Default nonce: 0, proposal_nonce: 0, creator: Default::default(), + deposit: Default::default(), last_activity: Default::default(), active_proposals: 0, } @@ -159,6 +162,11 @@ pub mod pallet { #[pallet::constant] type MultisigFee: Get>; + /// Deposit reserved for creating a multisig (returned when dissolved). + /// Keeps the state clean by incentivizing removal of unused multisigs. + #[pallet::constant] + type MultisigDeposit: Get>; + /// Deposit required per proposal (returned on execute or cancel) #[pallet::constant] type ProposalDeposit: Get>; @@ -178,6 +186,15 @@ pub mod pallet { #[pallet::constant] type PalletId: Get; + /// Maximum duration (in blocks) that a proposal can be set to expire in the future. + /// This prevents proposals from being created with extremely far expiry dates + /// that would lock deposits and bloat storage for extended periods. + /// + /// Example: If set to 100_000 blocks (~2 weeks at 12s blocks), + /// a proposal created at block 1000 cannot have expiry > 101_000. + #[pallet::constant] + type MaxExpiryDuration: Get>; + /// Weight information for extrinsics type WeightInfo: WeightInfo; } @@ -198,6 +215,7 @@ pub mod pallet { BlockNumberFor, ::AccountId, BoundedSignersOf, + BalanceOf, >; /// Type alias for ProposalData with proper bounds @@ -288,6 +306,12 @@ pub mod pallet { proposals_removed: u32, multisig_removed: bool, }, + /// A multisig account was dissolved and deposit returned + MultisigDissolved { + multisig_address: T::AccountId, + caller: T::AccountId, + deposit_returned: BalanceOf, + }, } #[pallet::error] @@ -318,6 +342,8 @@ pub mod pallet { NotEnoughApprovals, /// Proposal expiry is in the past ExpiryInPast, + /// Proposal expiry is too far in the future (exceeds MaxExpiryDuration) + ExpiryTooFar, /// Proposal has expired ProposalExpired, /// Call data too large @@ -336,6 +362,10 @@ pub mod pallet { ProposalNotExpired, /// Proposal is not active (already executed or cancelled) ProposalNotActive, + /// Cannot dissolve multisig with existing proposals (clear them first) + ProposalsExist, + /// Multisig account must have zero balance before dissolution + MultisigAccountNotZero, } #[pallet::call] @@ -396,6 +426,10 @@ pub mod pallet { ) .map_err(|_| Error::::InsufficientBalance)?; + // Reserve deposit from creator (will be returned on dissolve) + let deposit = T::MultisigDeposit::get(); + T::Currency::reserve(&creator, deposit).map_err(|_| Error::::InsufficientBalance)?; + // Convert sorted signers to bounded vec let bounded_signers: BoundedSignersOf = sorted_signers.try_into().map_err(|_| Error::::TooManySigners)?; @@ -412,6 +446,7 @@ pub mod pallet { nonce, proposal_nonce: 0, creator: creator.clone(), + deposit, last_activity: current_block, active_proposals: 0, }, @@ -479,6 +514,10 @@ pub mod pallet { let current_block = frame_system::Pallet::::block_number(); ensure!(expiry > current_block, Error::::ExpiryInPast); + // Validate expiry is not too far in the future + let max_expiry = current_block.saturating_add(T::MaxExpiryDuration::get()); + ensure!(expiry <= max_expiry, Error::::ExpiryTooFar); + // Calculate dynamic fee based on number of signers // Fee = Base + (Base * SignerCount * StepFactor) let base_fee = T::ProposalFee::get(); @@ -836,6 +875,57 @@ pub mod pallet { Ok(()) } + + /// Dissolve (remove) a multisig and recover the creation deposit. + /// + /// 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. + /// + /// The deposit is ALWAYS returned to the original `creator` stored in `MultisigData`. + #[pallet::call_index(6)] + #[pallet::weight(::WeightInfo::dissolve_multisig())] + pub fn dissolve_multisig( + origin: OriginFor, + multisig_address: T::AccountId, + ) -> DispatchResult { + let caller = 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); + + // 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()); + } + + // 4. Check if account balance is zero + 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); + + // 6. Remove multisig from storage + Multisigs::::remove(&multisig_address); + + // 7. Emit event + Self::deposit_event(Event::MultisigDissolved { + multisig_address, + caller, + deposit_returned: multisig_data.deposit, + }); + + Ok(()) + } } impl Pallet { diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index 22d7ebde..b1b0ef9e 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -92,9 +92,11 @@ parameter_types! { pub const MaxTotalProposalsInStorageParam: u32 = 20; // 2x MaxActiveProposals pub const MaxCallSizeParam: u32 = 1024; pub const MultisigFeeParam: Balance = 1000; // Non-refundable fee + pub const MultisigDepositParam: Balance = 500; // Refundable deposit 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) } impl pallet_multisig::Config for Test { @@ -105,9 +107,11 @@ impl pallet_multisig::Config for Test { type MaxTotalProposalsInStorage = MaxTotalProposalsInStorageParam; type MaxCallSize = MaxCallSizeParam; type MultisigFee = MultisigFeeParam; + type MultisigDeposit = MultisigDepositParam; type ProposalDeposit = ProposalDepositParam; type ProposalFee = ProposalFeeParam; type SignerStepFactor = SignerStepFactorParam; + type MaxExpiryDuration = MaxExpiryDurationParam; type PalletId = MultisigPalletId; type WeightInfo = (); } diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 26fefe7e..aa196439 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -66,6 +66,7 @@ fn create_multisig_works() { // Get initial balance let initial_balance = Balances::free_balance(creator); let fee = 1000; // MultisigFeeParam + let deposit = 500; // MultisigDepositParam // Create multisig assert_ok!(Multisig::create_multisig( @@ -74,9 +75,10 @@ fn create_multisig_works() { threshold, )); - // Check that fee was burned (no deposit anymore) - assert_eq!(Balances::reserved_balance(creator), 0); // No multisig deposit - assert_eq!(Balances::free_balance(creator), initial_balance - fee); + // Check balances + // Deposit is reserved, fee is burned + assert_eq!(Balances::reserved_balance(creator), deposit); + assert_eq!(Balances::free_balance(creator), initial_balance - fee - deposit); // Check that multisig was created let global_nonce = GlobalNonce::::get(); @@ -92,6 +94,7 @@ fn create_multisig_works() { assert_eq!(multisig_data.signers.to_vec(), signers); assert_eq!(multisig_data.active_proposals, 0); assert_eq!(multisig_data.creator, creator); + assert_eq!(multisig_data.deposit, deposit); // Check that event was emitted System::assert_last_event( @@ -920,6 +923,50 @@ fn propose_fails_with_expiry_in_past() { }); } +#[test] +fn propose_fails_with_expiry_too_far() { + new_test_ext().execute_with(|| { + System::set_block_number(100); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + + // MaxExpiryDurationParam = 10000 blocks (from mock.rs) + // Current block = 100 + // Max allowed expiry = 100 + 10000 = 10100 + + // Try to create proposal with expiry too far in the future + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call.clone(), 10101), + Error::::ExpiryTooFar + ); + + // Try with expiry way beyond the limit + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call.clone(), 20000), + Error::::ExpiryTooFar + ); + + // Valid: expiry exactly at max allowed + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call.clone(), + 10100 + )); + + // Move to next block and try again + System::set_block_number(101); + // Now max allowed = 101 + 10000 = 10101 + assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 10101)); + }); +} + #[test] fn propose_charges_correct_fee_with_signer_factor() { new_test_ext().execute_with(|| { @@ -953,3 +1000,51 @@ fn propose_charges_correct_fee_with_signer_factor() { assert_eq!(Balances::free_balance(proposer), initial_balance - deposit - expected_fee); }); } + +#[test] +fn dissolve_multisig_works() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let creator = alice(); + let signers = vec![bob(), charlie()]; + let deposit = 500; + let fee = 1000; + let initial_balance = Balances::free_balance(creator); + + // Create + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_eq!(Balances::reserved_balance(creator), deposit); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Try to dissolve immediately (success) + assert_ok!(Multisig::dissolve_multisig(RuntimeOrigin::signed(creator), multisig_address)); + + // Check cleanup + assert!(!Multisigs::::contains_key(multisig_address)); + assert_eq!(Balances::reserved_balance(creator), 0); + // Balance returned (minus burned fee) + assert_eq!(Balances::free_balance(creator), initial_balance - fee); + }); +} + +#[test] +fn dissolve_multisig_fails_with_proposals() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Create proposal + let call = make_call(vec![1]); + assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 100)); + + // Try to dissolve + assert_noop!( + Multisig::dissolve_multisig(RuntimeOrigin::signed(creator), multisig_address), + Error::::ProposalsExist + ); + }); +} diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index a61633ae..94e058e2 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -25,7 +25,7 @@ //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` // Executed Command: -// target/release/quantus-node +// ./target/release/quantus-node // benchmark // pallet // --chain @@ -61,6 +61,7 @@ pub trait WeightInfo { fn cancel(c: u32, ) -> Weight; fn remove_expired() -> Weight; fn claim_deposits() -> Weight; + fn dissolve_multisig() -> Weight; } /// Weights for `pallet_multisig` using the Substrate node and recommended hardware. @@ -69,89 +70,89 @@ 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(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `6771` - // Minimum execution time: 178_000_000 picoseconds. - Weight::from_parts(181_000_000, 6771) + // Estimated: `6787` + // Minimum execution time: 180_000_000 picoseconds. + Weight::from_parts(189_000_000, 6787) .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(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:2 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `521` + // Measured: `537` // Estimated: `33126` - // Minimum execution time: 163_000_000 picoseconds. - Weight::from_parts(63_843_863, 33126) - // Standard Error: 361 - .saturating_add(Weight::from_parts(165_427, 0).saturating_mul(c.into())) + // Minimum execution time: 151_000_000 picoseconds. + Weight::from_parts(59_408_420, 33126) + // Standard Error: 334 + .saturating_add(Weight::from_parts(162_998, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(3_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(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `781 + c * (1 ±0)` + // Measured: `797 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(14_289_646, 17058) - // Standard Error: 6 - .saturating_add(Weight::from_parts(383, 0).saturating_mul(c.into())) + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(14_329_162, 17058) + // Standard Error: 7 + .saturating_add(Weight::from_parts(384, 0).saturating_mul(c.into())) .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(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve_and_execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `749 + c * (1 ±0)` + // Measured: `765 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 17_000_000 picoseconds. - Weight::from_parts(17_839_063, 17058) - // Standard Error: 6 - .saturating_add(Weight::from_parts(769, 0).saturating_mul(c.into())) + // Minimum execution time: 16_000_000 picoseconds. + Weight::from_parts(17_973_860, 17058) + // Standard Error: 7 + .saturating_add(Weight::from_parts(733, 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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `749 + c * (1 ±0)` + // Measured: `765 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(12_401_943, 17058) - // Standard Error: 6 - .saturating_add(Weight::from_parts(527, 0).saturating_mul(c.into())) + // Minimum execution time: 11_000_000 picoseconds. + Weight::from_parts(12_507_801, 17058) + // Standard Error: 5 + .saturating_add(Weight::from_parts(435, 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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `779` + // Measured: `795` // Estimated: `17058` - // Minimum execution time: 20_000_000 picoseconds. + // Minimum execution time: 19_000_000 picoseconds. Weight::from_parts(20_000_000, 17058) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -159,16 +160,29 @@ impl WeightInfo for SubstrateWeight { /// Storage: `Multisig::Proposals` (r:6 w:5) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1891` + // Measured: `1907` // Estimated: `97398` - // Minimum execution time: 80_000_000 picoseconds. - Weight::from_parts(82_000_000, 97398) + // Minimum execution time: 76_000_000 picoseconds. + Weight::from_parts(80_000_000, 97398) .saturating_add(T::DbWeight::get().reads(7_u64)) .saturating_add(T::DbWeight::get().writes(6_u64)) } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:0) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) + fn dissolve_multisig() -> Weight { + // Proof Size summary in bytes: + // Measured: `500` + // Estimated: `17058` + // Minimum execution time: 50_000_000 picoseconds. + Weight::from_parts(52_000_000, 17058) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } } // For backwards compatibility and tests. @@ -176,89 +190,89 @@ 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(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: // Measured: `152` - // Estimated: `6771` - // Minimum execution time: 178_000_000 picoseconds. - Weight::from_parts(181_000_000, 6771) + // Estimated: `6787` + // Minimum execution time: 180_000_000 picoseconds. + Weight::from_parts(189_000_000, 6787) .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(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:2 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `521` + // Measured: `537` // Estimated: `33126` - // Minimum execution time: 163_000_000 picoseconds. - Weight::from_parts(63_843_863, 33126) - // Standard Error: 361 - .saturating_add(Weight::from_parts(165_427, 0).saturating_mul(c.into())) + // Minimum execution time: 151_000_000 picoseconds. + Weight::from_parts(59_408_420, 33126) + // Standard Error: 334 + .saturating_add(Weight::from_parts(162_998, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `781 + c * (1 ±0)` + // Measured: `797 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(14_289_646, 17058) - // Standard Error: 6 - .saturating_add(Weight::from_parts(383, 0).saturating_mul(c.into())) + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(14_329_162, 17058) + // Standard Error: 7 + .saturating_add(Weight::from_parts(384, 0).saturating_mul(c.into())) .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(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:1 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn approve_and_execute(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `749 + c * (1 ±0)` + // Measured: `765 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 17_000_000 picoseconds. - Weight::from_parts(17_839_063, 17058) - // Standard Error: 6 - .saturating_add(Weight::from_parts(769, 0).saturating_mul(c.into())) + // Minimum execution time: 16_000_000 picoseconds. + Weight::from_parts(17_973_860, 17058) + // Standard Error: 7 + .saturating_add(Weight::from_parts(733, 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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `749 + c * (1 ±0)` + // Measured: `765 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(12_401_943, 17058) - // Standard Error: 6 - .saturating_add(Weight::from_parts(527, 0).saturating_mul(c.into())) + // Minimum execution time: 11_000_000 picoseconds. + Weight::from_parts(12_507_801, 17058) + // Standard Error: 5 + .saturating_add(Weight::from_parts(435, 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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `779` + // Measured: `795` // Estimated: `17058` - // Minimum execution time: 20_000_000 picoseconds. + // Minimum execution time: 19_000_000 picoseconds. Weight::from_parts(20_000_000, 17058) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -266,14 +280,27 @@ impl WeightInfo for () { /// Storage: `Multisig::Proposals` (r:6 w:5) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3306), added: 5781, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) fn claim_deposits() -> Weight { // Proof Size summary in bytes: - // Measured: `1891` + // Measured: `1907` // Estimated: `97398` - // Minimum execution time: 80_000_000 picoseconds. - Weight::from_parts(82_000_000, 97398) + // Minimum execution time: 76_000_000 picoseconds. + Weight::from_parts(80_000_000, 97398) .saturating_add(RocksDbWeight::get().reads(7_u64)) .saturating_add(RocksDbWeight::get().writes(6_u64)) } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:0) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) + fn dissolve_multisig() -> Weight { + // Proof Size summary in bytes: + // Measured: `500` + // Estimated: `17058` + // Minimum execution time: 50_000_000 picoseconds. + Weight::from_parts(52_000_000, 17058) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 66947b36..fcfdefd8 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -575,9 +575,11 @@ parameter_types! { pub const MaxTotalProposalsInStorage: u32 = 200; // Max total in storage (Active + Executed + Cancelled) pub const MaxCallSize: u32 = 10240; // 10KB pub const MultisigFee: Balance = 100 * MILLI_UNIT; // 0.1 UNIT (non-refundable) + pub const MultisigDeposit: Balance = 500 * MILLI_UNIT; // 0.5 UNIT (refundable) pub const ProposalDeposit: Balance = 1000 * MILLI_UNIT; // 1 UNIT (locked until cleanup) pub const ProposalFee: Balance = 1000 * MILLI_UNIT; // 1 UNIT (non-refundable) pub const SignerStepFactorParam: Permill = Permill::from_percent(1); + pub const MaxExpiryDuration: BlockNumber = 100_800; // ~2 weeks at 12s blocks (14 days * 24h * 60m * 60s / 12s) } impl pallet_multisig::Config for Runtime { @@ -588,9 +590,11 @@ impl pallet_multisig::Config for Runtime { type MaxTotalProposalsInStorage = MaxTotalProposalsInStorage; type MaxCallSize = MaxCallSize; type MultisigFee = MultisigFee; + type MultisigDeposit = MultisigDeposit; type ProposalDeposit = ProposalDeposit; type ProposalFee = ProposalFee; type SignerStepFactor = SignerStepFactorParam; + type MaxExpiryDuration = MaxExpiryDuration; type PalletId = MultisigPalletId; type WeightInfo = pallet_multisig::weights::SubstrateWeight; } From cc4f82dd8a485044cd27d4a59416917f4208ebee Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 20 Jan 2026 14:42:03 +0800 Subject: [PATCH 17/27] feat: Fees to Treasury --- pallets/multisig/README.md | 15 ++++++++------- pallets/multisig/src/lib.rs | 17 +++++++++++------ pallets/multisig/src/mock.rs | 3 +++ pallets/multisig/src/tests.rs | 13 +++++++++++-- runtime/src/configs/mod.rs | 1 + 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index fce7eb64..8e5eccb4 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -48,8 +48,8 @@ Creates a new multisig account with deterministic address generation. - To create multiple multisigs with same signers, the nonce provides uniqueness **Economic Costs:** -- **MultisigFee**: 100 MILLI_UNIT (non-refundable, burned immediately) -- **MultisigDeposit**: 100 MILLI_UNIT (refundable after grace period when multisig becomes inactive) +- **MultisigFee**: 100 MILLI_UNIT (non-refundable, sent to treasury) +- **MultisigDeposit**: 100 MILLI_UNIT (refundable when multisig is dissolved) ### 2. Propose Transaction Creates a new proposal for multisig execution. @@ -68,7 +68,7 @@ Creates a new proposal for multisig execution. - Expiry must not exceed MaxExpiryDuration blocks from now (expiry ≤ current_block + MaxExpiryDuration) **Economic Costs:** -- **ProposalFee**: 1000 MILLI_UNIT (non-refundable, burned immediately) +- **ProposalFee**: 1000 MILLI_UNIT (non-refundable, sent to treasury) - **ProposalDeposit**: 1000 MILLI_UNIT (refundable when proposal executed/cancelled/removed) **Important:** Fee is ALWAYS paid, even if proposal expires or is cancelled. Only deposit is refundable. @@ -154,7 +154,7 @@ Batch cleanup operation to recover all eligible deposits. ## Economic Model ### Fees (Non-refundable) -Burned immediately upon payment, never returned: +Sent to treasury immediately upon payment, never returned: - **MultisigFee**: 100 MILLI_UNIT - paid on multisig creation - **ProposalFee**: 1000 MILLI_UNIT - paid on proposal creation @@ -318,7 +318,7 @@ This event structure is optimized for indexing by SubSquid and similar indexers: ## Security Considerations ### Spam Prevention -- Fees (non-refundable) prevent proposal spam +- Fees (non-refundable, sent to treasury) prevent proposal spam - Deposits (refundable) prevent storage bloat - MaxActiveProposals limits per-multisig open proposals @@ -328,8 +328,9 @@ This event structure is optimized for indexing by SubSquid and similar indexers: - Batch cleanup via claim_deposits for efficiency ### Economic Attacks -- Creating spam multisigs costs 100 MILLI_UNIT (burned) -- Creating spam proposals costs 1000 MILLI_UNIT (burned) + 1000 MILLI_UNIT (locked) +- Creating spam multisigs costs 100 MILLI_UNIT (sent to treasury) +- Creating spam proposals costs 1000 MILLI_UNIT (sent to treasury) + 1000 MILLI_UNIT (locked) +- Spam attempts generate protocol revenue - No limit on number of multisigs per user - No global limits - only per-multisig limits diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 65a44090..49e75db8 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -186,6 +186,11 @@ pub mod pallet { #[pallet::constant] type PalletId: Get; + /// Treasury account ID where fees are sent. + /// Fees (MultisigFee and ProposalFee) are transferred to this account + /// instead of being burned, providing revenue for the protocol. + type TreasuryAccountId: Get; + /// Maximum duration (in blocks) that a proposal can be set to expire in the future. /// This prevents proposals from being created with extremely far expiry dates /// that would lock deposits and bloat storage for extended periods. @@ -416,12 +421,12 @@ pub mod pallet { Error::::MultisigAlreadyExists ); - // Charge non-refundable fee (burned immediately) + // Charge non-refundable fee (sent to treasury) let fee = T::MultisigFee::get(); - let _ = T::Currency::withdraw( + T::Currency::transfer( &creator, + &T::TreasuryAccountId::get(), fee, - frame_support::traits::WithdrawReasons::FEE, frame_support::traits::ExistenceRequirement::KeepAlive, ) .map_err(|_| Error::::InsufficientBalance)?; @@ -530,11 +535,11 @@ pub mod pallet { let total_increase = fee_increase_per_signer.saturating_mul(signers_count.into()); let fee = base_fee.saturating_add(total_increase); - // Charge non-refundable fee (burned immediately) - let _ = T::Currency::withdraw( + // Charge non-refundable fee (sent to treasury) + T::Currency::transfer( &proposer, + &T::TreasuryAccountId::get(), fee, - frame_support::traits::WithdrawReasons::FEE, frame_support::traits::ExistenceRequirement::KeepAlive, ) .map_err(|_| Error::::InsufficientBalance)?; diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index b1b0ef9e..fd9c731d 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -97,6 +97,7 @@ parameter_types! { 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 TreasuryAccountParam: u64 = 999; // Treasury account for tests } impl pallet_multisig::Config for Test { @@ -111,6 +112,7 @@ impl pallet_multisig::Config for Test { type ProposalDeposit = ProposalDepositParam; type ProposalFee = ProposalFeeParam; type SignerStepFactor = SignerStepFactorParam; + type TreasuryAccountId = TreasuryAccountParam; type MaxExpiryDuration = MaxExpiryDurationParam; type PalletId = MultisigPalletId; type WeightInfo = (); @@ -127,6 +129,7 @@ pub fn new_test_ext() -> sp_io::TestExternalities { (3, 300000), // Charlie (4, 400000), // Dave (5, 500000), // Eve + (999, 1), // Treasury (fees will be sent here, needs ExistentialDeposit) ], } .assimilate_storage(&mut t) diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index aa196439..da5d832f 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -76,9 +76,11 @@ fn create_multisig_works() { )); // Check balances - // Deposit is reserved, fee is burned + // Deposit is reserved, fee is sent to treasury assert_eq!(Balances::reserved_balance(creator), deposit); assert_eq!(Balances::free_balance(creator), initial_balance - fee - deposit); + // Verify fee went to treasury (starts with 1 for ExistentialDeposit) + assert_eq!(Balances::free_balance(999), 1 + fee); // 999 is TreasuryAccountParam // Check that multisig was created let global_nonce = GlobalNonce::::get(); @@ -209,12 +211,16 @@ fn propose_works() { expiry )); - // Check balances - deposit reserved, fee burned + // Check balances - deposit reserved, fee sent to treasury assert_eq!(Balances::reserved_balance(proposer), proposal_deposit); assert_eq!( Balances::free_balance(proposer), initial_balance - proposal_deposit - proposal_fee ); + // Verify fee went to treasury + // Treasury balance = 1 (ExistentialDeposit) + 1000 (MultisigFee from creation) + 1020 + // (ProposalFee) + assert_eq!(Balances::free_balance(999), 1 + 1000 + proposal_fee); // 999 is TreasuryAccountParam // Check event let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); @@ -982,6 +988,7 @@ fn propose_charges_correct_fee_with_signer_factor() { let proposer = bob(); let call = make_call(vec![1, 2, 3]); let initial_balance = Balances::free_balance(proposer); + let treasury_initial = Balances::free_balance(999); // Treasury starts with multisig creation fee assert_ok!(Multisig::propose( RuntimeOrigin::signed(proposer), @@ -998,6 +1005,8 @@ fn propose_charges_correct_fee_with_signer_factor() { let deposit = 100; // ProposalDepositParam assert_eq!(Balances::free_balance(proposer), initial_balance - deposit - expected_fee); + // Verify fee went to treasury + assert_eq!(Balances::free_balance(999), treasury_initial + expected_fee); }); } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index fcfdefd8..ef0d1d7c 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -594,6 +594,7 @@ impl pallet_multisig::Config for Runtime { type ProposalDeposit = ProposalDeposit; type ProposalFee = ProposalFee; type SignerStepFactor = SignerStepFactorParam; + type TreasuryAccountId = TreasuryAccountId; type MaxExpiryDuration = MaxExpiryDuration; type PalletId = MultisigPalletId; type WeightInfo = pallet_multisig::weights::SubstrateWeight; From 299d71f377c9ee41bf6236e3d2ebf7dadbe19dcc Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Wed, 21 Jan 2026 10:24:42 +0800 Subject: [PATCH 18/27] feat: History removable only by signers --- pallets/multisig/Cargo.toml | 2 +- pallets/multisig/README.md | 143 ++++++++++++++++++++------- pallets/multisig/src/lib.rs | 14 ++- pallets/multisig/src/tests.rs | 52 +++++++++- pallets/multisig/src/weights.rs | 170 ++++++++++++++------------------ 5 files changed, 237 insertions(+), 144 deletions(-) diff --git a/pallets/multisig/Cargo.toml b/pallets/multisig/Cargo.toml index 1f31f398..0d768485 100644 --- a/pallets/multisig/Cargo.toml +++ b/pallets/multisig/Cargo.toml @@ -3,7 +3,7 @@ authors.workspace = true description = "Multisig pallet for Quantus" edition.workspace = true homepage.workspace = true -license = "Apache-2.0" +license = "MIT-0" name = "pallet-multisig" repository.workspace = true version = "1.0.0" diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index 8e5eccb4..ffddeab5 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -48,8 +48,8 @@ Creates a new multisig account with deterministic address generation. - To create multiple multisigs with same signers, the nonce provides uniqueness **Economic Costs:** -- **MultisigFee**: 100 MILLI_UNIT (non-refundable, sent to treasury) -- **MultisigDeposit**: 100 MILLI_UNIT (refundable when multisig is dissolved) +- **MultisigFee**: Non-refundable fee (spam prevention) → sent to treasury +- **MultisigDeposit**: Refundable deposit (storage rent) → returned when multisig dissolved ### 2. Propose Transaction Creates a new proposal for multisig execution. @@ -68,8 +68,8 @@ Creates a new proposal for multisig execution. - Expiry must not exceed MaxExpiryDuration blocks from now (expiry ≤ current_block + MaxExpiryDuration) **Economic Costs:** -- **ProposalFee**: 1000 MILLI_UNIT (non-refundable, sent to treasury) -- **ProposalDeposit**: 1000 MILLI_UNIT (refundable when proposal executed/cancelled/removed) +- **ProposalFee**: Non-refundable fee (spam prevention, scaled by signer count) → sent to treasury +- **ProposalDeposit**: Refundable deposit (storage rent) → returned when proposal removed **Important:** Fee is ALWAYS paid, even if proposal expires or is cancelled. Only deposit is refundable. @@ -116,24 +116,26 @@ Cancels a proposal (proposer only). **Note:** ProposalFee is NOT refunded - it was burned at proposal creation. ### 5. Remove Expired -Removes expired proposals from storage (cleanup mechanism). +Removes expired proposals from storage (cleanup mechanism). Only signers can call this. **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) - `proposal_hash: Hash` - Hash of expired proposal (REQUIRED) **Validation:** +- Caller must be a signer of the multisig - Proposal must exist - For Active proposals: must be expired (current_block > expiry) - For Executed/Cancelled proposals: can be removed anytime -- Anyone can call this function **Economic Effects:** -- ProposalDeposit returned to proposer +- ProposalDeposit returned to **original proposer** (not caller) - Proposal removed from storage **Economic Costs:** None (deposit always returned to proposer) +**Note:** This allows any signer to help cleanup storage, even if the proposer is inactive. The deposit always goes back to the proposer, preventing any incentive for malicious cleanup. + ### 6. Claim Deposits Batch cleanup operation to recover all eligible deposits. @@ -153,21 +155,70 @@ Batch cleanup operation to recover all eligible deposits. ## Economic Model -### Fees (Non-refundable) -Sent to treasury immediately upon payment, never returned: -- **MultisigFee**: 100 MILLI_UNIT - paid on multisig creation -- **ProposalFee**: 1000 MILLI_UNIT - paid on proposal creation - -### Deposits (Refundable) -Reserved and returned under specific conditions: -- **ProposalDeposit**: 1000 MILLI_UNIT - returned when proposal is removed (via remove_expired or claim_deposits) - -### Storage Limits -- **MaxSigners**: 10 - Maximum signers per multisig -- **MaxActiveProposals**: 100 - Maximum active (open) proposals per multisig at once -- **MaxTotalProposalsInStorage**: 200 - Maximum total proposals in storage (Active + Executed + Cancelled). This prevents unbounded storage growth and incentivizes cleanup -- **MaxCallSize**: 1024 bytes - Maximum encoded call size -- **MaxExpiryDuration**: Maximum blocks in the future that a proposal can expire (e.g., 100,000 blocks ≈ 2 weeks at 12s blocks). Prevents locking deposits for extremely long periods +### Fees (Non-refundable, sent to treasury) +**Purpose:** Spam prevention and protocol revenue + +- **MultisigFee**: + - Charged on multisig creation + - Sent immediately to treasury + - **Never returned** (even if multisig dissolved) + - Creates economic barrier to prevent spam multisig creation + +- **ProposalFee**: + - Charged on proposal creation + - **Dynamically scaled** by signer count: `BaseFee × (1 + SignerCount × StepFactor)` + - Sent immediately to treasury + - **Never returned** (even if proposal expires or is cancelled) + - Makes spam expensive, scales cost with multisig complexity + +**Why sent to treasury (not burned)?** +- Provides sustainable protocol revenue +- Spam attacks fund protocol development +- Available for governance spending + +### Deposits (Refundable, locked as storage rent) +**Purpose:** Compensate for on-chain storage, incentivize cleanup + +- **MultisigDeposit**: + - Reserved on multisig creation + - Returned when multisig dissolved (via `dissolve_multisig`) + - Locked until no proposals exist and balance is zero + - Opportunity cost incentivizes cleanup + +- **ProposalDeposit**: + - Reserved on proposal creation + - Returned when proposal removed (via `remove_expired` or `claim_deposits`) + - **Grace Period:** Not auto-returned on execution to enable: + - On-chain queryability for explorers + - Indexer processing time + - Audit trail availability + - Locked capital incentivizes active storage management + +### Storage Limits & Configuration +**Purpose:** Prevent unbounded storage growth and resource exhaustion + +- **MaxSigners**: Maximum signers per multisig + - Trade-off: Higher → more flexible governance, more computation per approval + +- **MaxActiveProposals**: Maximum concurrent active proposals per multisig + - Trade-off: Lower → better spam protection, may limit legitimate use + - Prevents flooding attacks + +- **MaxTotalProposalsInStorage**: Maximum total proposals (Active + Executed + Cancelled) + - Trade-off: Higher → more flexible, more storage risk + - Forces periodic cleanup to continue operating + - Recommend: 2× MaxActiveProposals + +- **MaxCallSize**: Maximum encoded call size in bytes + - Trade-off: Larger → more flexibility, more storage per proposal + - Should accommodate common operations (transfers, staking, governance) + +- **MaxExpiryDuration**: Maximum blocks in the future for proposal expiry + - Trade-off: Shorter → faster turnover, may not suit slow decision-making + - Prevents infinite-duration deposit locks + - Should exceed typical multisig decision timeframes + +**Configuration values are runtime-specific.** See runtime config for production values. ## Storage @@ -328,11 +379,15 @@ This event structure is optimized for indexing by SubSquid and similar indexers: - Batch cleanup via claim_deposits for efficiency ### Economic Attacks -- Creating spam multisigs costs 100 MILLI_UNIT (sent to treasury) -- Creating spam proposals costs 1000 MILLI_UNIT (sent to treasury) + 1000 MILLI_UNIT (locked) -- Spam attempts generate protocol revenue -- No limit on number of multisigs per user -- No global limits - only per-multisig limits +- **Multisig Spam:** Costs MultisigFee (sent to treasury) + - No refund even if never used + - Economic barrier to creation spam +- **Proposal Spam:** Costs ProposalFee (sent to treasury) + ProposalDeposit (locked) + - Fee never returned (even if expired/cancelled) + - Deposit locked until cleanup + - Cost scales with multisig size (dynamic pricing) +- **Result:** Spam attempts generate protocol revenue +- **No global limits:** Only per-multisig limits (decentralized resistance) ### Call Execution - Calls execute with multisig_address as origin @@ -342,24 +397,38 @@ This event structure is optimized for indexing by SubSquid and similar indexers: ## Configuration Example + ```rust impl pallet_multisig::Config for Runtime { type RuntimeCall = RuntimeCall; type Currency = Balances; - type MaxSigners = ConstU32<100>; - type MaxActiveProposals = ConstU32<100>; - type MaxTotalProposalsInStorage = ConstU32<200>; - type MaxCallSize = ConstU32<10240>; - type MultisigFee = ConstU128<{ 100 * MILLI_UNIT }>; - type ProposalDeposit = ConstU128<{ 1000 * MILLI_UNIT }>; - type ProposalFee = ConstU128<{ 1000 * MILLI_UNIT }>; - type SignerStepFactor = Permill::from_percent(1); - type MaxExpiryDuration = ConstU32<100_800>; // ~2 weeks at 12s blocks + type TreasuryAccountId = TreasuryAccountId; // Where fees are sent + + // Storage limits (prevent unbounded growth) + type MaxSigners = ConstU32<100>; // Max complexity + type MaxActiveProposals = ConstU32<100>; // Spam protection + type MaxTotalProposalsInStorage = ConstU32<200>; // Total cap (recommend: 2× active) + type MaxCallSize = ConstU32<10240>; // Per-proposal storage limit + 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 SignerStepFactor = Permill::from_percent(1); // Dynamic pricing (1% per signer) + type PalletId = ConstPalletId(*b"py/mltsg"); type WeightInfo = pallet_multisig::weights::SubstrateWeight; } ``` +**Parameter Selection Considerations:** +- **High-value chains:** Lower fees, higher deposits, tighter limits +- **Low-value chains:** Higher fees (maintain spam protection), lower deposits +- **Enterprise use:** Higher MaxSigners, longer MaxExpiryDuration +- **Public use:** Moderate limits, shorter expiry for faster turnover + ## License -Apache-2.0 +MIT-0 diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 49e75db8..33d4ecd8 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -731,14 +731,15 @@ pub mod pallet { /// Remove a proposal and return deposit to proposer /// - /// Can be called to clean up proposals that are: + /// Can only be called by signers of the multisig. + /// + /// Can be used to clean up proposals that are: /// - Active and expired (past expiry block) /// - Executed (status changed to Executed) /// - Cancelled (status changed to Cancelled) /// - /// Grace period protection: - /// - Within grace period: only proposer can remove - /// - After grace period: anyone can remove (deposit still returned to proposer) + /// The deposit is always returned to the original proposer, not the caller. + /// This allows signers to help clean up storage even if proposer is inactive. /// /// This enforces storage cleanup - users must remove old proposals to recover deposits. #[pallet::call_index(4)] @@ -750,6 +751,11 @@ pub mod pallet { ) -> DispatchResult { let caller = ensure_signed(origin)?; + // Verify caller is a signer + let multisig_data = + Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; + ensure!(multisig_data.signers.contains(&caller), Error::::NotASigner); + // Get proposal let proposal = Proposals::::get(&multisig_address, proposal_hash) .ok_or(Error::::ProposalNotFound)?; diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index da5d832f..8467968b 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -452,9 +452,9 @@ fn remove_expired_works_after_grace_period() { // Move past expiry + grace period (100 blocks) System::set_block_number(expiry + 101); - // Anyone can remove after grace period + // Any signer can remove after grace period (charlie is a signer) assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(dave()), + RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); @@ -498,9 +498,9 @@ fn remove_expired_works_for_executed_proposal_after_grace_period() { // Move past grace period from execution System::set_block_number(102); // 1 (execution) + 100 (grace) + 1 - // Remove executed proposal + // Remove executed proposal (charlie is a signer) assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(dave()), + RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); @@ -510,6 +510,50 @@ fn remove_expired_works_for_executed_proposal_after_grace_period() { }); } +#[test] +fn remove_expired_fails_for_non_signer() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + let call = make_call(vec![1, 2, 3]); + let expiry = 1000; + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + expiry + )); + + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + + // Move past expiry + System::set_block_number(expiry + 1); + + // Dave is not a signer, should fail + assert_noop!( + Multisig::remove_expired( + RuntimeOrigin::signed(dave()), + multisig_address, + proposal_hash + ), + Error::::NotASigner + ); + + // But charlie (who is a signer) can do it + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + }); +} + #[test] fn claim_deposits_works() { new_test_ext().execute_with(|| { diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 94e058e2..b924f5dd 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-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-01-21, 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` @@ -28,20 +28,13 @@ // ./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=50 +// --repeat=20 +// --template=.maintain/frame-weight-template.hbs +// --output=pallets/multisig/src/weights.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -61,7 +54,6 @@ pub trait WeightInfo { fn cancel(c: u32, ) -> Weight; fn remove_expired() -> Weight; fn claim_deposits() -> Weight; - fn dissolve_multisig() -> Weight; } /// Weights for `pallet_multisig` using the Substrate node and recommended hardware. @@ -71,30 +63,34 @@ impl WeightInfo for SubstrateWeight { /// 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(3322), added: 5797, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `152` + // Measured: `456` // Estimated: `6787` - // Minimum execution time: 180_000_000 picoseconds. - Weight::from_parts(189_000_000, 6787) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().writes(2_u64)) + // Minimum execution time: 193_000_000 picoseconds. + Weight::from_parts(202_000_000, 6787) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:2 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `537` + // Measured: `841` // Estimated: `33126` - // Minimum execution time: 151_000_000 picoseconds. - Weight::from_parts(59_408_420, 33126) - // Standard Error: 334 - .saturating_add(Weight::from_parts(162_998, 0).saturating_mul(c.into())) - .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().writes(2_u64)) + // Minimum execution time: 163_000_000 picoseconds. + Weight::from_parts(75_630_635, 33126) + // Standard Error: 324 + .saturating_add(Weight::from_parts(159_235, 0).saturating_mul(c.into())) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) @@ -105,10 +101,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `797 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(14_329_162, 17058) + // Minimum execution time: 12_000_000 picoseconds. + Weight::from_parts(13_927_745, 17058) // Standard Error: 7 - .saturating_add(Weight::from_parts(384, 0).saturating_mul(c.into())) + .saturating_add(Weight::from_parts(345, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -121,10 +117,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `765 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 16_000_000 picoseconds. - Weight::from_parts(17_973_860, 17058) - // Standard Error: 7 - .saturating_add(Weight::from_parts(733, 0).saturating_mul(c.into())) + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(17_252_468, 17058) + // Standard Error: 9 + .saturating_add(Weight::from_parts(753, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -138,22 +134,22 @@ impl WeightInfo for SubstrateWeight { // Measured: `765 + c * (1 ±0)` // Estimated: `17058` // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(12_507_801, 17058) - // Standard Error: 5 - .saturating_add(Weight::from_parts(435, 0).saturating_mul(c.into())) + Weight::from_parts(12_138_163, 17058) + // Standard Error: 9 + .saturating_add(Weight::from_parts(463, 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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: // Measured: `795` // Estimated: `17058` - // Minimum execution time: 19_000_000 picoseconds. - Weight::from_parts(20_000_000, 17058) + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(21_000_000, 17058) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -165,24 +161,11 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1907` // Estimated: `97398` - // Minimum execution time: 76_000_000 picoseconds. - Weight::from_parts(80_000_000, 97398) + // Minimum execution time: 71_000_000 picoseconds. + Weight::from_parts(79_000_000, 97398) .saturating_add(T::DbWeight::get().reads(7_u64)) .saturating_add(T::DbWeight::get().writes(6_u64)) } - /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:1 w:0) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - fn dissolve_multisig() -> Weight { - // Proof Size summary in bytes: - // Measured: `500` - // Estimated: `17058` - // Minimum execution time: 50_000_000 picoseconds. - Weight::from_parts(52_000_000, 17058) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) - } } // For backwards compatibility and tests. @@ -191,30 +174,34 @@ impl WeightInfo for () { /// 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(3322), added: 5797, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `152` + // Measured: `456` // Estimated: `6787` - // Minimum execution time: 180_000_000 picoseconds. - Weight::from_parts(189_000_000, 6787) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().writes(2_u64)) + // Minimum execution time: 193_000_000 picoseconds. + Weight::from_parts(202_000_000, 6787) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) /// Storage: `Multisig::Proposals` (r:2 w:1) /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn propose(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `537` + // Measured: `841` // Estimated: `33126` - // Minimum execution time: 151_000_000 picoseconds. - Weight::from_parts(59_408_420, 33126) - // Standard Error: 334 - .saturating_add(Weight::from_parts(162_998, 0).saturating_mul(c.into())) - .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().writes(2_u64)) + // Minimum execution time: 163_000_000 picoseconds. + Weight::from_parts(75_630_635, 33126) + // Standard Error: 324 + .saturating_add(Weight::from_parts(159_235, 0).saturating_mul(c.into())) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) } /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) @@ -225,10 +212,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `797 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(14_329_162, 17058) + // Minimum execution time: 12_000_000 picoseconds. + Weight::from_parts(13_927_745, 17058) // Standard Error: 7 - .saturating_add(Weight::from_parts(384, 0).saturating_mul(c.into())) + .saturating_add(Weight::from_parts(345, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -241,10 +228,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `765 + c * (1 ±0)` // Estimated: `17058` - // Minimum execution time: 16_000_000 picoseconds. - Weight::from_parts(17_973_860, 17058) - // Standard Error: 7 - .saturating_add(Weight::from_parts(733, 0).saturating_mul(c.into())) + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(17_252_468, 17058) + // Standard Error: 9 + .saturating_add(Weight::from_parts(753, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -258,22 +245,22 @@ impl WeightInfo for () { // Measured: `765 + c * (1 ±0)` // Estimated: `17058` // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(12_507_801, 17058) - // Standard Error: 5 - .saturating_add(Weight::from_parts(435, 0).saturating_mul(c.into())) + Weight::from_parts(12_138_163, 17058) + // Standard Error: 9 + .saturating_add(Weight::from_parts(463, 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(13593), added: 16068, mode: `MaxEncodedLen`) /// Storage: `Multisig::Multisigs` (r:1 w:1) /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: // Measured: `795` // Estimated: `17058` - // Minimum execution time: 19_000_000 picoseconds. - Weight::from_parts(20_000_000, 17058) + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(21_000_000, 17058) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -285,22 +272,9 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1907` // Estimated: `97398` - // Minimum execution time: 76_000_000 picoseconds. - Weight::from_parts(80_000_000, 97398) + // Minimum execution time: 71_000_000 picoseconds. + Weight::from_parts(79_000_000, 97398) .saturating_add(RocksDbWeight::get().reads(7_u64)) .saturating_add(RocksDbWeight::get().writes(6_u64)) } - /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:1 w:0) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - fn dissolve_multisig() -> Weight { - // Proof Size summary in bytes: - // Measured: `500` - // Estimated: `17058` - // Minimum execution time: 50_000_000 picoseconds. - Weight::from_parts(52_000_000, 17058) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().writes(1_u64)) - } } From c904336ab5c78210e8025fd7231a2899dcf1eced Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Wed, 21 Jan 2026 11:08:15 +0800 Subject: [PATCH 19/27] fix: Weights --- pallets/multisig/src/weights.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index b924f5dd..9a3b90ce 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -54,6 +54,7 @@ pub trait WeightInfo { fn cancel(c: u32, ) -> Weight; fn remove_expired() -> Weight; fn claim_deposits() -> Weight; + fn dissolve_multisig() -> Weight; } /// Weights for `pallet_multisig` using the Substrate node and recommended hardware. @@ -166,6 +167,17 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(7_u64)) .saturating_add(T::DbWeight::get().writes(6_u64)) } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) + fn dissolve_multisig() -> Weight { + // Proof Size summary in bytes: + // Measured: `500` + // Estimated: `6787` + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(18_000_000, 6787) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } } // For backwards compatibility and tests. @@ -277,4 +289,15 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(7_u64)) .saturating_add(RocksDbWeight::get().writes(6_u64)) } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) + fn dissolve_multisig() -> Weight { + // Proof Size summary in bytes: + // Measured: `500` + // Estimated: `6787` + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(18_000_000, 6787) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } From 2fa9af7b9ffd930bc5eb1fe8c34d872b139a99ab Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Thu, 22 Jan 2026 15:33:06 +0800 Subject: [PATCH 20/27] feat: Fees burned --- pallets/multisig/README.md | 28 ++++++++++++++-------------- pallets/multisig/src/lib.rs | 17 ++++++----------- pallets/multisig/src/mock.rs | 3 --- pallets/multisig/src/tests.rs | 13 +++---------- runtime/src/configs/mod.rs | 1 - 5 files changed, 23 insertions(+), 39 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index ffddeab5..a1030543 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -48,7 +48,7 @@ Creates a new multisig account with deterministic address generation. - To create multiple multisigs with same signers, the nonce provides uniqueness **Economic Costs:** -- **MultisigFee**: Non-refundable fee (spam prevention) → sent to treasury +- **MultisigFee**: Non-refundable fee (spam prevention) → burned - **MultisigDeposit**: Refundable deposit (storage rent) → returned when multisig dissolved ### 2. Propose Transaction @@ -68,7 +68,7 @@ Creates a new proposal for multisig execution. - Expiry must not exceed MaxExpiryDuration blocks from now (expiry ≤ current_block + MaxExpiryDuration) **Economic Costs:** -- **ProposalFee**: Non-refundable fee (spam prevention, scaled by signer count) → sent to treasury +- **ProposalFee**: Non-refundable fee (spam prevention, scaled by signer count) → burned - **ProposalDeposit**: Refundable deposit (storage rent) → returned when proposal removed **Important:** Fee is ALWAYS paid, even if proposal expires or is cancelled. Only deposit is refundable. @@ -155,26 +155,27 @@ Batch cleanup operation to recover all eligible deposits. ## Economic Model -### Fees (Non-refundable, sent to treasury) -**Purpose:** Spam prevention and protocol revenue +### Fees (Non-refundable, burned) +**Purpose:** Spam prevention and deflationary pressure - **MultisigFee**: - Charged on multisig creation - - Sent immediately to treasury + - Burned immediately (reduces total supply) - **Never returned** (even if multisig dissolved) - Creates economic barrier to prevent spam multisig creation - **ProposalFee**: - Charged on proposal creation - **Dynamically scaled** by signer count: `BaseFee × (1 + SignerCount × StepFactor)` - - Sent immediately to treasury + - Burned immediately (reduces total supply) - **Never returned** (even if proposal expires or is cancelled) - Makes spam expensive, scales cost with multisig complexity -**Why sent to treasury (not burned)?** -- Provides sustainable protocol revenue -- Spam attacks fund protocol development -- Available for governance spending +**Why burned (not sent to treasury)?** +- Creates deflationary pressure on token supply +- Simpler implementation (no treasury dependency) +- Spam attacks reduce circulating supply +- Lower transaction costs (withdraw vs transfer) ### Deposits (Refundable, locked as storage rent) **Purpose:** Compensate for on-chain storage, incentivize cleanup @@ -369,7 +370,7 @@ This event structure is optimized for indexing by SubSquid and similar indexers: ## Security Considerations ### Spam Prevention -- Fees (non-refundable, sent to treasury) prevent proposal spam +- Fees (non-refundable, burned) prevent proposal spam - Deposits (refundable) prevent storage bloat - MaxActiveProposals limits per-multisig open proposals @@ -379,10 +380,10 @@ This event structure is optimized for indexing by SubSquid and similar indexers: - Batch cleanup via claim_deposits for efficiency ### Economic Attacks -- **Multisig Spam:** Costs MultisigFee (sent to treasury) +- **Multisig Spam:** Costs MultisigFee (burned, reduces supply) - No refund even if never used - Economic barrier to creation spam -- **Proposal Spam:** Costs ProposalFee (sent to treasury) + ProposalDeposit (locked) +- **Proposal Spam:** Costs ProposalFee (burned, reduces supply) + ProposalDeposit (locked) - Fee never returned (even if expired/cancelled) - Deposit locked until cleanup - Cost scales with multisig size (dynamic pricing) @@ -402,7 +403,6 @@ This event structure is optimized for indexing by SubSquid and similar indexers: impl pallet_multisig::Config for Runtime { type RuntimeCall = RuntimeCall; type Currency = Balances; - type TreasuryAccountId = TreasuryAccountId; // Where fees are sent // Storage limits (prevent unbounded growth) type MaxSigners = ConstU32<100>; // Max complexity diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 33d4ecd8..d5267406 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -186,11 +186,6 @@ pub mod pallet { #[pallet::constant] type PalletId: Get; - /// Treasury account ID where fees are sent. - /// Fees (MultisigFee and ProposalFee) are transferred to this account - /// instead of being burned, providing revenue for the protocol. - type TreasuryAccountId: Get; - /// Maximum duration (in blocks) that a proposal can be set to expire in the future. /// This prevents proposals from being created with extremely far expiry dates /// that would lock deposits and bloat storage for extended periods. @@ -421,12 +416,12 @@ pub mod pallet { Error::::MultisigAlreadyExists ); - // Charge non-refundable fee (sent to treasury) + // Charge non-refundable fee (burned) let fee = T::MultisigFee::get(); - T::Currency::transfer( + let _ = T::Currency::withdraw( &creator, - &T::TreasuryAccountId::get(), fee, + frame_support::traits::WithdrawReasons::FEE, frame_support::traits::ExistenceRequirement::KeepAlive, ) .map_err(|_| Error::::InsufficientBalance)?; @@ -535,11 +530,11 @@ pub mod pallet { let total_increase = fee_increase_per_signer.saturating_mul(signers_count.into()); let fee = base_fee.saturating_add(total_increase); - // Charge non-refundable fee (sent to treasury) - T::Currency::transfer( + // Charge non-refundable fee (burned) + let _ = T::Currency::withdraw( &proposer, - &T::TreasuryAccountId::get(), fee, + frame_support::traits::WithdrawReasons::FEE, frame_support::traits::ExistenceRequirement::KeepAlive, ) .map_err(|_| Error::::InsufficientBalance)?; diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index fd9c731d..b1b0ef9e 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -97,7 +97,6 @@ parameter_types! { 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 TreasuryAccountParam: u64 = 999; // Treasury account for tests } impl pallet_multisig::Config for Test { @@ -112,7 +111,6 @@ impl pallet_multisig::Config for Test { type ProposalDeposit = ProposalDepositParam; type ProposalFee = ProposalFeeParam; type SignerStepFactor = SignerStepFactorParam; - type TreasuryAccountId = TreasuryAccountParam; type MaxExpiryDuration = MaxExpiryDurationParam; type PalletId = MultisigPalletId; type WeightInfo = (); @@ -129,7 +127,6 @@ pub fn new_test_ext() -> sp_io::TestExternalities { (3, 300000), // Charlie (4, 400000), // Dave (5, 500000), // Eve - (999, 1), // Treasury (fees will be sent here, needs ExistentialDeposit) ], } .assimilate_storage(&mut t) diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 8467968b..6c6ab85e 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -76,11 +76,9 @@ fn create_multisig_works() { )); // Check balances - // Deposit is reserved, fee is sent to treasury + // Deposit is reserved, fee is burned assert_eq!(Balances::reserved_balance(creator), deposit); assert_eq!(Balances::free_balance(creator), initial_balance - fee - deposit); - // Verify fee went to treasury (starts with 1 for ExistentialDeposit) - assert_eq!(Balances::free_balance(999), 1 + fee); // 999 is TreasuryAccountParam // Check that multisig was created let global_nonce = GlobalNonce::::get(); @@ -217,10 +215,7 @@ fn propose_works() { Balances::free_balance(proposer), initial_balance - proposal_deposit - proposal_fee ); - // Verify fee went to treasury - // Treasury balance = 1 (ExistentialDeposit) + 1000 (MultisigFee from creation) + 1020 - // (ProposalFee) - assert_eq!(Balances::free_balance(999), 1 + 1000 + proposal_fee); // 999 is TreasuryAccountParam + // Fee is burned (reduces total issuance) // Check event let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); @@ -1032,7 +1027,6 @@ fn propose_charges_correct_fee_with_signer_factor() { let proposer = bob(); let call = make_call(vec![1, 2, 3]); let initial_balance = Balances::free_balance(proposer); - let treasury_initial = Balances::free_balance(999); // Treasury starts with multisig creation fee assert_ok!(Multisig::propose( RuntimeOrigin::signed(proposer), @@ -1049,8 +1043,7 @@ fn propose_charges_correct_fee_with_signer_factor() { let deposit = 100; // ProposalDepositParam assert_eq!(Balances::free_balance(proposer), initial_balance - deposit - expected_fee); - // Verify fee went to treasury - assert_eq!(Balances::free_balance(999), treasury_initial + expected_fee); + // Fee is burned (reduces total issuance) }); } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index ef0d1d7c..fcfdefd8 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -594,7 +594,6 @@ impl pallet_multisig::Config for Runtime { type ProposalDeposit = ProposalDeposit; type ProposalFee = ProposalFee; type SignerStepFactor = SignerStepFactorParam; - type TreasuryAccountId = TreasuryAccountId; type MaxExpiryDuration = MaxExpiryDuration; type PalletId = MultisigPalletId; type WeightInfo = pallet_multisig::weights::SubstrateWeight; From 96f91bdd580202fc23a126b565f40816eb3534af Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Fri, 23 Jan 2026 06:25:26 +0800 Subject: [PATCH 21/27] feat: Filibuster protection --- pallets/multisig/README.md | 29 ++- pallets/multisig/src/lib.rs | 97 ++++++++-- pallets/multisig/src/tests.rs | 354 +++++++++++++++++----------------- 3 files changed, 272 insertions(+), 208 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index a1030543..bdd8d7b2 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -64,6 +64,7 @@ Creates a new proposal for multisig execution. - Call size must be ≤ MaxCallSize - Multisig cannot have MaxActiveProposals or more open proposals - Multisig cannot have MaxTotalProposalsInStorage or more total proposals in storage +- Caller cannot exceed their per-signer proposal limit (`MaxTotalProposalsInStorage / signers_count`) - Expiry must be in the future (expiry > current_block) - Expiry must not exceed MaxExpiryDuration blocks from now (expiry ≤ current_block + MaxExpiryDuration) @@ -209,6 +210,10 @@ Batch cleanup operation to recover all eligible deposits. - Trade-off: Higher → more flexible, more storage risk - Forces periodic cleanup to continue operating - Recommend: 2× MaxActiveProposals + - **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 + - Example: 20 total, 5 signers → 4 proposals max per signer - **MaxCallSize**: Maximum encoded call size in bytes - Trade-off: Larger → more flexibility, more storage per proposal @@ -227,13 +232,14 @@ Batch cleanup operation to recover all eligible deposits. Stores multisig account data: ```rust MultisigData { - signers: BoundedVec, // List of authorized signers - 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 (for MaxActiveProposals check) + signers: BoundedVec, // List of authorized signers + 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 (for MaxActiveProposals check) + proposals_per_signer: BoundedBTreeMap, // Per-signer proposal count (filibuster protection) } ``` @@ -301,6 +307,7 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `InsufficientBalance` - Not enough funds for fee/deposit - `TooManyActiveProposals` - Multisig has MaxActiveProposals open proposals - `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) @@ -373,6 +380,7 @@ This event structure is optimized for indexing by SubSquid and similar indexers: - Fees (non-refundable, burned) prevent proposal spam - Deposits (refundable) prevent storage bloat - MaxActiveProposals limits per-multisig open proposals +- Per-signer limits prevent single signer from monopolizing storage (filibuster protection) ### Storage Cleanup - Grace period allows proposers priority cleanup @@ -387,7 +395,12 @@ This event structure is optimized for indexing by SubSquid and similar indexers: - Fee never returned (even if expired/cancelled) - Deposit locked until cleanup - Cost scales with multisig size (dynamic pricing) -- **Result:** Spam attempts generate protocol revenue +- **Filibuster Attack (Single Signer Monopolization):** + - **Attack:** One signer tries to fill entire proposal queue + - **Defense:** Per-signer limit caps each at `MaxTotalProposalsInStorage / signers_count` + - **Effect:** Other signers retain their fair quota + - **Cost:** Attacker still pays fees for their proposals (burned) +- **Result:** Spam attempts reduce circulating supply - **No global limits:** Only per-multisig limits (decentralized resistance) ### Call Execution diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index d5267406..f8a67d57 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -34,13 +34,14 @@ mod tests; pub mod weights; use codec::{Decode, Encode, MaxEncodedLen}; -use frame_support::{traits::Get, BoundedVec}; +use frame_support::{traits::Get, BoundedBTreeMap, BoundedVec}; use scale_info::TypeInfo; 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 @@ -57,10 +58,18 @@ pub struct MultisigData { pub last_activity: BlockNumber, /// Number of currently active (non-executed/non-cancelled) proposals pub active_proposals: u32, + /// Counter of proposals in storage per signer (for filibuster protection) + pub proposals_per_signer: BoundedProposalsPerSigner, } -impl Default - for MultisigData +impl< + BlockNumber: Default, + AccountId: Default, + BoundedSigners: Default, + Balance: Default, + BoundedProposalsPerSigner: Default, + > Default + for MultisigData { fn default() -> Self { Self { @@ -72,6 +81,7 @@ impl = BoundedVec::MaxCallSize>; + /// Type alias for bounded proposals per signer map + pub type BoundedProposalsPerSignerOf = + BoundedBTreeMap<::AccountId, u32, ::MaxSigners>; + /// Type alias for MultisigData with proper bounds pub type MultisigDataOf = MultisigData< BlockNumberFor, ::AccountId, BoundedSignersOf, BalanceOf, + BoundedProposalsPerSignerOf, >; /// Type alias for ProposalData with proper bounds @@ -354,6 +369,8 @@ pub mod pallet { TooManyActiveProposals, /// Too many total proposals in storage for this multisig (cleanup required) TooManyProposalsInStorage, + /// This signer has too many proposals in storage (filibuster protection) + TooManyProposalsPerSigner, /// Insufficient balance for deposit InsufficientBalance, /// Proposal has active deposit @@ -449,6 +466,7 @@ pub mod pallet { deposit, last_activity: current_block, active_proposals: 0, + proposals_per_signer: Default::default(), }, ); @@ -492,6 +510,9 @@ pub mod pallet { Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; ensure!(multisig_data.signers.contains(&proposer), Error::::NotASigner); + // Get signers count (used for multiple checks below) + let signers_count = multisig_data.signers.len() as u32; + // Check active proposals limit ensure!( multisig_data.active_proposals < T::MaxActiveProposals::get(), @@ -507,6 +528,18 @@ pub mod pallet { Error::::TooManyProposalsInStorage ); + // 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 = + multisig_data.proposals_per_signer.get(&proposer).copied().unwrap_or(0); + ensure!(proposer_count < max_per_signer, Error::::TooManyProposalsPerSigner); + // Check call size ensure!(call.len() as u32 <= T::MaxCallSize::get(), Error::::CallTooLarge); @@ -521,7 +554,6 @@ pub mod pallet { // Calculate dynamic fee based on number of signers // Fee = Base + (Base * SignerCount * StepFactor) let base_fee = T::ProposalFee::get(); - let signers_count = multisig_data.signers.len() as u32; let step_factor = T::SignerStepFactor::get(); // Calculate extra fee: (Base * Factor) * Count @@ -592,10 +624,17 @@ pub mod pallet { // Store proposal Proposals::::insert(&multisig_address, proposal_hash, proposal); - // Increment active proposals counter + // 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 + .proposals_per_signer + .try_insert(proposer.clone(), current_count.saturating_add(1)); } }); @@ -777,14 +816,24 @@ pub mod pallet { // Remove proposal from storage Proposals::::remove(&multisig_address, proposal_hash); - // Decrement active proposals counter ONLY if it was still active - if proposal.status == ProposalStatus::Active { - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { + // Decrement counters + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + // Decrement active proposals counter ONLY if it was still active + if proposal.status == ProposalStatus::Active { multisig.active_proposals = multisig.active_proposals.saturating_sub(1); } - }); - } + + // Always decrement per-signer counter (counts all proposals in storage) + if let Some(count) = multisig.proposals_per_signer.get_mut(&proposal.proposer) { + *count = count.saturating_sub(1); + // Remove entry if count reaches zero to save storage + if *count == 0 { + multisig.proposals_per_signer.remove(&proposal.proposer); + } + } + } + }); // Emit event Self::deposit_event(Event::ProposalRemoved { @@ -852,14 +901,26 @@ pub mod pallet { Proposals::::remove(&multisig_address, hash); removed_count = removed_count.saturating_add(1); - // Decrement active proposals counter ONLY if still active - if proposal.status == ProposalStatus::Active { - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { + // Decrement counters + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + // Decrement active proposals counter ONLY if still active + if proposal.status == ProposalStatus::Active { multisig.active_proposals = multisig.active_proposals.saturating_sub(1); } - }); - } + + // Always decrement per-signer counter (counts all proposals in storage) + if let Some(count) = + multisig.proposals_per_signer.get_mut(&proposal.proposer) + { + *count = count.saturating_sub(1); + // Remove entry if count reaches zero to save storage + if *count == 0 { + multisig.proposals_per_signer.remove(&proposal.proposer); + } + } + } + }); // Emit event for each removed proposal Self::deposit_event(Event::ProposalRemoved { diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 6c6ab85e..2e379297 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -646,14 +646,10 @@ fn too_many_proposals_in_storage_fails() { let creator = alice(); let signers = vec![bob(), charlie()]; assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - // MaxActiveProposalsParam = 10, MaxTotalProposalsInStorageParam = 20 - // Strategy: Keep active < 10, but total = 20 - // Create cycles: propose -> execute/cancel to keep active low but total high - - // Cycle 1: Create 10, execute all 10 (active=0, total=10 executed) + // MaxTotal = 20, 2 signers = 10 each + // Create 10 proposals from Bob and execute 5 to free active slots for i in 0..10 { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( @@ -662,48 +658,43 @@ fn too_many_proposals_in_storage_fails() { call.clone(), 1000 )); - // Calculate hash after propose (uses incremented nonce) - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); - // Immediately execute to keep active low - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); + if i < 5 { + let hash = calculate_last_proposal_hash(multisig_address, &call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + hash + )); + } } + // Now Bob has 10 (5 active, 5 executed) - // Cycle 2: Create 9 more (active=9, total=19) - for i in 10..19 { + // Create 10 proposals from Charlie and execute 5 + for i in 10..20 { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), + RuntimeOrigin::signed(charlie()), multisig_address, - call, - 2000 + call.clone(), + 1000 )); + if i < 15 { + let hash = calculate_last_proposal_hash(multisig_address, &call); + assert_ok!(Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address, hash)); + } } + // Now: Total = 20 (10 Bob, 10 Charlie), Active = 10 - // Now: 9 Active, 10 Executed = 19 total in storage - // One more to reach limit - let call = make_call(vec![19]); - assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 2000)); + // Execute one to free active slot + let call = make_call(vec![5]); + let hash = calculate_proposal_hash_with_nonce(&call, 5); + assert_ok!(Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, hash)); - // Now: 10 Active, 10 Executed = 20 total (AT LIMIT) - // Try to create 21st proposal - should fail with TooManyProposalsInStorage - // Active check: 10 < 10 = false, but let's execute one first - let call = make_call(vec![10]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, 10); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Now: 9 Active, 11 Executed = 20 total - // Active check will pass (9 < 10), but total check will fail + // Now: Total = 20, Active = 9 + // Try to add 21st - should fail on total limit let call = make_call(vec![99]); assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 3000), + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 2000), Error::::TooManyProposalsInStorage ); }); @@ -717,101 +708,70 @@ fn total_proposals_counts_executed_and_cancelled() { let creator = alice(); let signers = vec![bob(), charlie()]; assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - // Create 10 active proposals + // Test that total storage counts ALL proposals (Active + Executed + Cancelled) + // Strategy: Keep active low by executing most, but fill total storage + + // Bob creates 10 and executes 8, cancels 1 = 10 total, 1 active for i in 0..10 { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, - call, + call.clone(), 1000 )); + + if i < 8 { + // Execute first 8 + let hash = calculate_last_proposal_hash(multisig_address, &call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + hash + )); + } else if i == 8 { + // Cancel one + let hash = calculate_last_proposal_hash(multisig_address, &call); + assert_ok!(Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, hash)); + } } + // Bob: 1 Active + 8 Executed + 1 Cancelled = 10 total - // Execute 5 of them (they become Executed status, still in storage) - for i in 0..5 { + // Charlie creates 10, executes 8, cancels 1 = 10 total, 1 active + for i in 10..20 { let call = make_call(vec![i as u8]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, i); - // Auto-execute by reaching threshold - assert_ok!(Multisig::approve( + assert_ok!(Multisig::propose( RuntimeOrigin::signed(charlie()), multisig_address, - proposal_hash + call.clone(), + 1000 )); - } - // Cancel 3 more (they become Cancelled status, still in storage) - for i in 5..8 { - let call = make_call(vec![i as u8]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, i); - assert_ok!(Multisig::cancel( - RuntimeOrigin::signed(bob()), - multisig_address, - proposal_hash - )); + if i < 18 { + let hash = calculate_last_proposal_hash(multisig_address, &call); + assert_ok!(Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address, hash)); + } else if i == 18 { + let hash = calculate_last_proposal_hash(multisig_address, &call); + assert_ok!(Multisig::cancel( + RuntimeOrigin::signed(charlie()), + multisig_address, + hash + )); + } } + // Charlie: 1 Active + 8 Executed + 1 Cancelled = 10 total + // TOTAL: 2 Active + 16 Executed + 2 Cancelled = 20 (AT LIMIT) - // Now we have: 2 Active + 5 Executed + 3 Cancelled = 10 total - // MaxActiveProposals = 10, MaxTotalProposalsInStorage = 20 - // We can add 8 more active (to reach 10 active) and 10 more total (to reach 20 total) - - // Add 8 more active proposals - should work (2+8=10 active, 10+8=18 total) - for i in 20..28 { - let call = make_call(vec![i as u8]); - assert_ok!(Multisig::propose( + // Try to add 21st - should fail with TooManyProposalsInStorage + assert_noop!( + Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, - call, + make_call(vec![99]), 2000 - )); - } - - // Execute one to make room for active (now 9 active, 19 total) - let call = make_call(vec![8]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, 8); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Add one more (10 active, 20 total = AT LIMIT) - let call = make_call(vec![30]); - assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 2000)); - - // Now: 10 Active (9,20-28) + 6 Executed (0-4,8) + 3 Cancelled (5-7) = 19 total - // Execute one more to free up active but keep total at 19 - let call = make_call(vec![9]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, 9); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Now: 9 Active (20-28) + 7 Executed (0-4,8,9) + 3 Cancelled (5-7) = 19 total - // Add one more to reach 20 total - let call = make_call(vec![31]); - assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 3000)); - - // Now: 10 Active (20-28,31) + 7 Executed + 3 Cancelled = 20 total - // Execute one to make room for active check - let call = make_call(vec![20]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, 10); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Now: 9 Active (21-28,31) + 8 Executed + 3 Cancelled = 20 total - // Active check will pass (9 < 10), but total check will fail - let call = make_call(vec![99]); - assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 4000), + ), Error::::TooManyProposalsInStorage ); }); @@ -825,114 +785,92 @@ fn cleanup_allows_new_proposals() { let creator = alice(); let signers = vec![bob(), charlie()]; assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - let multisig_address = Multisig::derive_multisig_address(&signers, 0); - // Create 10 proposals + // Create 10 proposals from Bob (execute 8, leave 2 active), all expire at block 100 for i in 0..10 { let call = make_call(vec![i as u8]); - let expiry = 100; // All expire at block 100 assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, - call, - expiry - )); - } - - // Execute first 5 to make room (no longer active, but still in storage) - for i in 0..5 { - let call = make_call(vec![i as u8]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, i); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash + call.clone(), + 100 )); + if i < 8 { + let hash = calculate_last_proposal_hash(multisig_address, &call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + hash + )); + } } + // Bob: 2 Active + 8 Executed = 10 total - // Move past expiry for the remaining 5 - System::set_block_number(101); - - // Now: 5 Active(expired) + 5 Executed = 10 total - // Create 10 more proposals (cycling execute to keep active low) + // Create 10 proposals from Charlie (execute 8, leave 2 active) for i in 10..20 { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), + RuntimeOrigin::signed(charlie()), multisig_address, call.clone(), - 200 + 100 )); - // Calculate hash after propose - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); - // Execute immediately if i < 15 to keep active count low - if i < 15 { - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); + if i < 18 { + let hash = calculate_last_proposal_hash(multisig_address, &call); + assert_ok!(Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address, hash)); } } + // Charlie: 2 Active + 8 Executed = 10 total + // TOTAL: 4 Active + 16 Executed = 20 (AT LIMIT) - // Now: 5 Active(expired) + 5 Active(fresh) + 10 Executed = 20 total - // Active check: 10 < 10 = false, let's execute one - // vec![15] was created in for i in 10..20, it was 6th iteration (nonce=15) - let call = make_call(vec![15]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, 15); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Now: 5 Active(expired) + 4 Active(fresh) + 11 Executed = 20 total - // Active: 9 < 10 ✓, Total: 20 = 20 ✗ - let call = make_call(vec![99]); + // Try to add 21st - should fail assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 200), + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + make_call(vec![99]), + 100 + ), Error::::TooManyProposalsInStorage ); - // Cleanup the 5 expired ones - for i in 5..10 { + // Move past expiry + grace period + System::set_block_number(101); + + // Remove Bob's 2 expired active proposals (8-9) + for i in 8..10 { let call = make_call(vec![i as u8]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, i); + let hash = calculate_proposal_hash_with_nonce(&call, i); assert_ok!(Multisig::remove_expired( RuntimeOrigin::signed(bob()), multisig_address, - proposal_hash + hash )); } + // Bob: 0 Active + 8 Executed = 8 total + // TOTAL: 2 Active + 16 Executed = 18 - // Now: 4 Active + 11 Executed = 15 total. Can add 5 more! - for i in 20..25 { - let call = make_call(vec![i as u8]); + // Now Bob can add 2 more (his per-signer limit is 10) + for i in 20..22 { assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, - call, - 300 + make_call(vec![i]), + 200 )); } + // Bob: 2 Active + 8 Executed = 10 total + // TOTAL: 4 Active + 16 Executed = 20 (AT LIMIT again) - // Now: 9 Active + 11 Executed = 20 total (AT LIMIT) - // Execute one more to make room for active check - // vec![20] was created in for i in 20..25, first iteration (nonce=20) - let call = make_call(vec![20]); - let proposal_hash = calculate_proposal_hash_with_nonce(&call, 20); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Now: 8 Active + 12 Executed = 20 total - // Active: 8 < 10 ✓, Total: 20 = 20 ✗ - let call = make_call(vec![98]); + // Try to add 21st - should still fail assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 300), + Multisig::propose( + RuntimeOrigin::signed(charlie()), + multisig_address, + make_call(vec![98]), + 200 + ), Error::::TooManyProposalsInStorage ); }); @@ -1094,3 +1032,55 @@ fn dissolve_multisig_fails_with_proposals() { ); }); } + +#[test] +fn per_signer_proposal_limit_enforced() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let creator = alice(); + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // MaxActiveProposals = 10, MaxTotalProposalsInStorage = 20 + // With 2 signers, each can have max 20/2 = 10 proposals + + // Bob creates 10 proposals + for i in 0..10 { + let call = make_call(vec![i as u8]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + call.clone(), + 1000 + )); + + // Execute first 5 to stay under active limit + if i < 5 { + let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + )); + } + } + + // Now: 5 Active + 5 Executed = 10 total for Bob + // Bob tries to create 11th - should fail with TooManyProposalsPerSigner + let call = make_call(vec![99]); + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 1000), + Error::::TooManyProposalsPerSigner + ); + + // But Charlie can still create (hasn't hit his limit) + let call = make_call(vec![100]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(charlie()), + multisig_address, + call, + 1000 + )); + }); +} From e4f8bd7d4f42894e4fa2b1c21c9d16850bd3e673 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Fri, 23 Jan 2026 07:04:07 +0800 Subject: [PATCH 22/27] feat: Proposals auto cleaning --- pallets/multisig/README.md | 59 ++++---- pallets/multisig/src/lib.rs | 169 ++++++++++++++--------- pallets/multisig/src/mock.rs | 2 +- pallets/multisig/src/tests.rs | 250 +++++++++++----------------------- 4 files changed, 219 insertions(+), 261 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index bdd8d7b2..fc5f16ea 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -76,7 +76,7 @@ Creates a new proposal for multisig execution. ### 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.** +to or above the threshold, the transaction will be automatically executed and immediately removed from storage.** **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) @@ -91,14 +91,14 @@ to or above the threshold, the transaction will be automatically executed.** **Auto-Execution:** When approval count reaches the threshold: - Encoded call is executed as multisig_address origin -- ProposalDeposit returned to proposer -- Proposal removed from storage +- Proposal **immediately removed** from storage +- ProposalDeposit **immediately returned** to proposer - TransactionExecuted event emitted with execution result -**Economic Costs:** None (only transaction fees, deposit returned on execution) +**Economic Costs:** None (deposit immediately returned on execution) ### 4. Cancel Transaction -Cancels a proposal (proposer only). +Cancels a proposal and immediately removes it from storage (proposer only). **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) @@ -106,18 +106,21 @@ Cancels a proposal (proposer only). **Validation:** - Caller must be the proposer -- Proposal must exist +- Proposal must exist and be Active **Economic Effects:** -- ProposalDeposit returned to proposer -- Proposal removed from storage +- Proposal **immediately removed** from storage +- ProposalDeposit **immediately returned** to proposer +- Counters decremented -**Economic Costs:** None (deposit returned) +**Economic Costs:** None (deposit immediately returned) **Note:** ProposalFee is NOT refunded - it was burned at proposal creation. ### 5. Remove Expired -Removes expired proposals from storage (cleanup mechanism). Only signers can call this. +Manually removes expired proposals from storage. Only signers can call this. + +**Important:** This is rarely needed because expired proposals are automatically cleaned up when anyone creates a new proposal in the same multisig. **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) @@ -125,35 +128,42 @@ Removes expired proposals from storage (cleanup mechanism). Only signers can cal **Validation:** - Caller must be a signer of the multisig -- Proposal must exist -- For Active proposals: must be expired (current_block > expiry) -- For Executed/Cancelled proposals: can be removed anytime +- 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. **Economic Effects:** - ProposalDeposit returned to **original proposer** (not caller) - Proposal removed from storage +- Counters decremented **Economic Costs:** None (deposit always returned to proposer) -**Note:** This allows any signer to help cleanup storage, even if the proposer is inactive. The deposit always goes back to the proposer, preventing any incentive for malicious cleanup. +**Auto-Cleanup:** When anyone calls `propose()`, all expired proposals are automatically removed first, making this function often unnecessary. ### 6. Claim Deposits -Batch cleanup operation to recover all eligible deposits. +Batch cleanup operation to recover all expired proposal deposits. + +**Important:** This is rarely needed because expired proposals are automatically cleaned up when anyone creates a new proposal in the same multisig. **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) **Validation:** - Only cleans proposals where caller is proposer -- For Active proposals: must be expired (current_block > expiry) -- For Executed/Cancelled proposals: can always be removed +- Only removes Active+Expired proposals (Executed/Cancelled already auto-removed) +- Must be expired (current_block > expiry) **Economic Effects:** - Returns all eligible proposal deposits to caller -- Removes all eligible proposals from storage +- Removes all expired proposals from storage +- Counters decremented **Economic Costs:** None (only returns deposits) +**Auto-Cleanup:** When anyone calls `propose()`, all expired proposals are automatically removed first, making this function often unnecessary. + ## Economic Model ### Fees (Non-refundable, burned) @@ -189,12 +199,13 @@ Batch cleanup operation to recover all eligible deposits. - **ProposalDeposit**: - Reserved on proposal creation - - Returned when proposal removed (via `remove_expired` or `claim_deposits`) - - **Grace Period:** Not auto-returned on execution to enable: - - On-chain queryability for explorers - - Indexer processing time - - Audit trail availability - - Locked capital incentivizes active storage management + - **Auto-Returned Immediately:** + - When proposal executed (threshold reached) + - When proposal cancelled (proposer cancels) + - **Manual Cleanup Required:** + - Expired proposals: Must be manually removed OR auto-cleaned on next `propose()` + - **Auto-Cleanup:** When anyone creates new proposal, all expired proposals cleaned automatically + - No grace period needed - executed/cancelled proposals auto-removed ### Storage Limits & Configuration **Purpose:** Prevent unbounded storage growth and resource exhaustion diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index f8a67d57..d8328034 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -510,6 +510,55 @@ pub mod pallet { Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; ensure!(multisig_data.signers.contains(&proposer), Error::::NotASigner); + // Auto-cleanup expired proposals before creating new one + // This ensures storage is managed proactively during normal operation + let current_block = frame_system::Pallet::::block_number(); + let expired_proposals: Vec<(T::Hash, T::AccountId, BalanceOf)> = + Proposals::::iter_prefix(&multisig_address) + .filter_map(|(hash, proposal)| { + if proposal.status == ProposalStatus::Active && + current_block > proposal.expiry + { + Some((hash, proposal.proposer, proposal.deposit)) + } else { + None + } + }) + .collect(); + + // Remove expired proposals and return deposits + for (hash, expired_proposer, deposit) in expired_proposals.iter() { + Proposals::::remove(&multisig_address, hash); + T::Currency::unreserve(expired_proposer, *deposit); + + // Decrement counters + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); + + if let Some(count) = multisig.proposals_per_signer.get_mut(expired_proposer) + { + *count = count.saturating_sub(1); + if *count == 0 { + multisig.proposals_per_signer.remove(expired_proposer); + } + } + } + }); + + // Emit event for each removed proposal + Self::deposit_event(Event::ProposalRemoved { + multisig_address: multisig_address.clone(), + proposal_hash: *hash, + proposer: expired_proposer.clone(), + removed_by: proposer.clone(), + }); + } + + // Reload multisig data after potential cleanup + let multisig_data = + Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; + // Get signers count (used for multiple checks below) let signers_count = multisig_data.signers.len() as u32; @@ -544,7 +593,6 @@ pub mod pallet { ensure!(call.len() as u32 <= T::MaxCallSize::get(), Error::::CallTooLarge); // Validate expiry is in the future - let current_block = frame_system::Pallet::::block_number(); ensure!(expiry > current_block, Error::::ExpiryInPast); // Validate expiry is not too far in the future @@ -731,7 +779,7 @@ pub mod pallet { let canceller = ensure_signed(origin)?; // Get proposal - let mut proposal = Proposals::::get(&multisig_address, proposal_hash) + let proposal = Proposals::::get(&multisig_address, proposal_hash) .ok_or(Error::::ProposalNotFound)?; // Check if caller is the proposer @@ -740,16 +788,24 @@ pub mod pallet { // Check if proposal is still active ensure!(proposal.status == ProposalStatus::Active, Error::::ProposalNotActive); - // Mark as cancelled (deposit stays locked until removal) - proposal.status = ProposalStatus::Cancelled; + // Remove proposal from storage immediately + Proposals::::remove(&multisig_address, proposal_hash); - // Update proposal in storage - Proposals::::insert(&multisig_address, proposal_hash, proposal.clone()); + // Return deposit to proposer immediately + T::Currency::unreserve(&proposal.proposer, proposal.deposit); - // Decrement active proposals counter + // Decrement counters 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(&proposal.proposer) { + *count = count.saturating_sub(1); + if *count == 0 { + multisig.proposals_per_signer.remove(&proposal.proposer); + } + } } }); @@ -763,19 +819,14 @@ pub mod pallet { Ok(()) } - /// Remove a proposal and return deposit to proposer + /// Remove expired proposals and return deposits to proposers /// /// Can only be called by signers of the multisig. - /// - /// Can be used to clean up proposals that are: - /// - Active and expired (past expiry block) - /// - Executed (status changed to Executed) - /// - Cancelled (status changed to Cancelled) + /// Only removes Active proposals that have expired (past expiry block). + /// Executed and Cancelled proposals are automatically cleaned up immediately. /// /// The deposit is always returned to the original proposer, not the caller. - /// This allows signers to help clean up storage even if proposer is inactive. - /// - /// This enforces storage cleanup - users must remove old proposals to recover deposits. + /// This allows any signer to help clean up storage even if proposer is inactive. #[pallet::call_index(4)] #[pallet::weight(::WeightInfo::remove_expired())] pub fn remove_expired( @@ -794,21 +845,13 @@ pub mod pallet { let proposal = Proposals::::get(&multisig_address, proposal_hash) .ok_or(Error::::ProposalNotFound)?; - let current_block = frame_system::Pallet::::block_number(); - - // Determine if proposal can be removed - let can_remove = match proposal.status { - ProposalStatus::Active => { - // Active proposals can be removed only if expired - current_block > proposal.expiry - }, - ProposalStatus::Executed | ProposalStatus::Cancelled => { - // Executed/Cancelled proposals can always be removed - true - }, - }; + // Only Active proposals can be manually removed (Executed/Cancelled already + // auto-removed) + ensure!(proposal.status == ProposalStatus::Active, Error::::ProposalNotActive); - ensure!(can_remove, Error::::ProposalNotExpired); + // Check if expired + let current_block = frame_system::Pallet::::block_number(); + ensure!(current_block > proposal.expiry, Error::::ProposalNotExpired); // Return deposit to proposer T::Currency::unreserve(&proposal.proposer, proposal.deposit); @@ -819,15 +862,11 @@ pub mod pallet { // Decrement counters Multisigs::::mutate(&multisig_address, |maybe_multisig| { if let Some(multisig) = maybe_multisig { - // Decrement active proposals counter ONLY if it was still active - if proposal.status == ProposalStatus::Active { - multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - } + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - // Always decrement per-signer counter (counts all proposals in storage) + // Decrement per-signer counter if let Some(count) = multisig.proposals_per_signer.get_mut(&proposal.proposer) { *count = count.saturating_sub(1); - // Remove entry if count reaches zero to save storage if *count == 0 { multisig.proposals_per_signer.remove(&proposal.proposer); } @@ -846,15 +885,16 @@ pub mod pallet { Ok(()) } - /// Claim all deposits from cancelled, executed, and expired proposals + /// Claim all deposits from expired proposals /// - /// This is a batch operation that removes all proposals where: + /// This is a batch operation that removes all expired proposals where: /// - Caller is the proposer - /// - Proposal is Executed, Cancelled, or Active+Expired - /// - Grace period has elapsed since status changed + /// - Proposal is Active and past expiry block + /// + /// Note: Executed and Cancelled proposals are automatically cleaned up immediately, + /// so only Active+Expired proposals need manual cleanup. /// /// Returns all proposal deposits to the proposer in a single transaction. - /// This enforces storage cleanup - users must actively clean up to recover deposits. #[pallet::call_index(5)] #[pallet::weight(::WeightInfo::claim_deposits())] pub fn claim_deposits( @@ -869,6 +909,7 @@ pub mod pallet { 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<(T::Hash, ProposalDataOf)> = Proposals::::iter_prefix(&multisig_address) .filter(|(_, proposal)| { @@ -877,17 +918,9 @@ pub mod pallet { return false; } - // Check if proposal can be removed - match proposal.status { - ProposalStatus::Active => { - // Active proposals need to be expired - current_block > proposal.expiry - }, - ProposalStatus::Executed | ProposalStatus::Cancelled => { - // Executed/Cancelled can always be removed - true - }, - } + // Only Active proposals can exist (Executed/Cancelled auto-removed) + // Must be expired to remove + proposal.status == ProposalStatus::Active && current_block > proposal.expiry }) .collect(); @@ -901,20 +934,16 @@ pub mod pallet { Proposals::::remove(&multisig_address, hash); removed_count = removed_count.saturating_add(1); - // Decrement counters + // Decrement counters (all are Active since Executed/Cancelled auto-removed) Multisigs::::mutate(&multisig_address, |maybe_multisig| { if let Some(multisig) = maybe_multisig { - // Decrement active proposals counter ONLY if still active - if proposal.status == ProposalStatus::Active { - multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - } + multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - // Always decrement per-signer counter (counts all proposals in storage) + // Decrement per-signer counter if let Some(count) = multisig.proposals_per_signer.get_mut(&proposal.proposer) { *count = count.saturating_sub(1); - // Remove entry if count reaches zero to save storage if *count == 0 { multisig.proposals_per_signer.remove(&proposal.proposer); } @@ -1046,29 +1075,37 @@ pub mod pallet { fn do_execute( multisig_address: T::AccountId, proposal_hash: T::Hash, - mut proposal: ProposalDataOf, + proposal: ProposalDataOf, ) -> DispatchResult { // CHECKS: Decode the call (validation) let call = ::RuntimeCall::decode(&mut &proposal.call[..]) .map_err(|_| Error::::InvalidCall)?; - // EFFECTS: Mark as executed (deposit stays locked until removal) - // This MUST happen before call.dispatch() to prevent reentrancy - proposal.status = ProposalStatus::Executed; + // EFFECTS: Remove proposal from storage BEFORE external interaction (reentrancy + // protection) + Proposals::::remove(&multisig_address, proposal_hash); - // EFFECTS: Update proposal in storage BEFORE external interaction - Proposals::::insert(&multisig_address, proposal_hash, proposal.clone()); + // EFFECTS: Return deposit to proposer BEFORE external interaction + T::Currency::unreserve(&proposal.proposer, proposal.deposit); // EFFECTS: Update multisig counters BEFORE external interaction Multisigs::::mutate(&multisig_address, |maybe_multisig| { if let Some(multisig) = maybe_multisig { multisig.last_activity = frame_system::Pallet::::block_number(); multisig.active_proposals = multisig.active_proposals.saturating_sub(1); + + // Decrement per-signer counter + if let Some(count) = multisig.proposals_per_signer.get_mut(&proposal.proposer) { + *count = count.saturating_sub(1); + if *count == 0 { + multisig.proposals_per_signer.remove(&proposal.proposer); + } + } } }); // INTERACTIONS: NOW execute the call as the multisig account - // Even if this call tries to re-enter, the proposal is already marked as Executed + // Proposal already removed, so reentrancy cannot affect storage let result = call.dispatch(frame_system::RawOrigin::Signed(multisig_address.clone()).into()); diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index b1b0ef9e..6ccfeaa2 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -88,7 +88,7 @@ impl pallet_balances::Config for Test { parameter_types! { pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); pub const MaxSignersParam: u32 = 10; - pub const MaxActiveProposalsParam: u32 = 10; // For testing + pub const MaxActiveProposalsParam: u32 = 50; // For testing pub const MaxTotalProposalsInStorageParam: u32 = 20; // 2x MaxActiveProposals pub const MaxCallSizeParam: u32 = 1024; pub const MultisigFeeParam: Balance = 1000; // Non-refundable fee diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 2e379297..fc6b5af3 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -310,19 +310,18 @@ fn approve_auto_executes_when_threshold_reached() { let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); - // Charlie approves - threshold reached (2/2) + // Charlie approves - threshold reached (2/2), auto-executes and removes assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); - // Check that proposal was executed (status changed, but still in storage) - let proposal = crate::Proposals::::get(multisig_address, proposal_hash).unwrap(); - assert_eq!(proposal.status, ProposalStatus::Executed); + // Check that proposal was executed and immediately removed from storage + assert!(crate::Proposals::::get(multisig_address, proposal_hash).is_none()); - // Deposit is still locked (not returned yet) - assert_eq!(Balances::reserved_balance(bob()), 100); // Still reserved + // Deposit should be returned immediately + assert_eq!(Balances::reserved_balance(bob()), 0); // No longer reserved // Check event was emitted System::assert_has_event( @@ -363,19 +362,18 @@ fn cancel_works() { let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); - // Cancel the proposal + // Cancel the proposal - immediately removes and returns deposit assert_ok!(Multisig::cancel( RuntimeOrigin::signed(proposer), multisig_address, proposal_hash )); - // Proposal should still exist but marked as cancelled - let proposal = crate::Proposals::::get(multisig_address, proposal_hash).unwrap(); - assert_eq!(proposal.status, ProposalStatus::Cancelled); + // Proposal should be immediately removed from storage + assert!(crate::Proposals::::get(multisig_address, proposal_hash).is_none()); - // Deposit is still locked (not returned yet) - assert_eq!(Balances::reserved_balance(proposer), 100); + // Deposit should be returned immediately + assert_eq!(Balances::reserved_balance(proposer), 0); // Check event System::assert_last_event( @@ -405,17 +403,17 @@ fn cancel_fails_if_already_executed() { let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); - // Approve to execute + // Approve to execute (auto-executes and removes proposal) assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); - // Try to cancel executed proposal + // Try to cancel executed proposal (already removed, so ProposalNotFound) assert_noop!( Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, proposal_hash), - Error::::ProposalNotActive + Error::::ProposalNotFound ); }); } @@ -463,7 +461,7 @@ fn remove_expired_works_after_grace_period() { } #[test] -fn remove_expired_works_for_executed_proposal_after_grace_period() { +fn executed_proposals_auto_removed() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -483,25 +481,28 @@ fn remove_expired_works_for_executed_proposal_after_grace_period() { let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); - // Execute + // Execute - should auto-remove proposal and return deposit assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, proposal_hash )); - // Move past grace period from execution - System::set_block_number(102); // 1 (execution) + 100 (grace) + 1 + // Proposal should be immediately removed + assert!(crate::Proposals::::get(multisig_address, proposal_hash).is_none()); - // Remove executed proposal (charlie is a signer) - assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - - // Deposit returned + // Deposit should be immediately returned assert_eq!(Balances::reserved_balance(bob()), 0); + + // Trying to remove again should fail (already removed) + assert_noop!( + Multisig::remove_expired( + RuntimeOrigin::signed(charlie()), + multisig_address, + proposal_hash + ), + Error::::ProposalNotFound + ); }); } @@ -649,7 +650,8 @@ fn too_many_proposals_in_storage_fails() { let multisig_address = Multisig::derive_multisig_address(&signers, 0); // MaxTotal = 20, 2 signers = 10 each - // Create 10 proposals from Bob and execute 5 to free active slots + // Executed/Cancelled proposals are auto-removed, so only Active count toward storage + // Create 10 active proposals from Bob for i in 0..10 { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( @@ -658,18 +660,10 @@ fn too_many_proposals_in_storage_fails() { call.clone(), 1000 )); - if i < 5 { - let hash = calculate_last_proposal_hash(multisig_address, &call); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - hash - )); - } } - // Now Bob has 10 (5 active, 5 executed) + // Bob has 10 active = 10 total (at per-signer limit) - // Create 10 proposals from Charlie and execute 5 + // Create 10 active proposals from Charlie for i in 10..20 { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( @@ -678,19 +672,10 @@ fn too_many_proposals_in_storage_fails() { call.clone(), 1000 )); - if i < 15 { - let hash = calculate_last_proposal_hash(multisig_address, &call); - assert_ok!(Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address, hash)); - } } - // Now: Total = 20 (10 Bob, 10 Charlie), Active = 10 - - // Execute one to free active slot - let call = make_call(vec![5]); - let hash = calculate_proposal_hash_with_nonce(&call, 5); - assert_ok!(Multisig::approve(RuntimeOrigin::signed(charlie()), multisig_address, hash)); + // Charlie has 10 active = 10 total (at per-signer limit) + // Total: 20 active (AT LIMIT) - // Now: Total = 20, Active = 9 // Try to add 21st - should fail on total limit let call = make_call(vec![99]); assert_noop!( @@ -701,7 +686,7 @@ fn too_many_proposals_in_storage_fails() { } #[test] -fn total_proposals_counts_executed_and_cancelled() { +fn only_active_proposals_remain_in_storage() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -710,10 +695,9 @@ fn total_proposals_counts_executed_and_cancelled() { assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); let multisig_address = Multisig::derive_multisig_address(&signers, 0); - // Test that total storage counts ALL proposals (Active + Executed + Cancelled) - // Strategy: Keep active low by executing most, but fill total storage + // Test that only Active proposals remain in storage (Executed/Cancelled auto-removed) - // Bob creates 10 and executes 8, cancels 1 = 10 total, 1 active + // Bob creates 10, 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( @@ -723,62 +707,46 @@ fn total_proposals_counts_executed_and_cancelled() { 1000 )); - if i < 8 { - // Execute first 8 + if i < 5 { let hash = calculate_last_proposal_hash(multisig_address, &call); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, hash )); - } else if i == 8 { - // Cancel one + } else if i == 5 { let hash = calculate_last_proposal_hash(multisig_address, &call); assert_ok!(Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, hash)); } } - // Bob: 1 Active + 8 Executed + 1 Cancelled = 10 total + // Bob now has 4 Active in storage (i=6,7,8,9), 5 executed + 1 cancelled were removed - // Charlie creates 10, executes 8, cancels 1 = 10 total, 1 active - for i in 10..20 { - let call = make_call(vec![i as u8]); + // Bob can create 6 more to reach his per-signer limit (10) + for i in 10..16 { assert_ok!(Multisig::propose( - RuntimeOrigin::signed(charlie()), + RuntimeOrigin::signed(bob()), multisig_address, - call.clone(), - 1000 + make_call(vec![i]), + 2000 )); - - if i < 18 { - let hash = calculate_last_proposal_hash(multisig_address, &call); - assert_ok!(Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address, hash)); - } else if i == 18 { - let hash = calculate_last_proposal_hash(multisig_address, &call); - assert_ok!(Multisig::cancel( - RuntimeOrigin::signed(charlie()), - multisig_address, - hash - )); - } } - // Charlie: 1 Active + 8 Executed + 1 Cancelled = 10 total - // TOTAL: 2 Active + 16 Executed + 2 Cancelled = 20 (AT LIMIT) + // Bob: 10 Active (at per-signer limit) - // Try to add 21st - should fail with TooManyProposalsInStorage + // Bob cannot create 11th assert_noop!( Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, make_call(vec![99]), - 2000 + 3000 ), - Error::::TooManyProposalsInStorage + Error::::TooManyProposalsPerSigner ); }); } #[test] -fn cleanup_allows_new_proposals() { +fn auto_cleanup_allows_new_proposals() { new_test_ext().execute_with(|| { System::set_block_number(1); @@ -787,92 +755,42 @@ fn cleanup_allows_new_proposals() { assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); let multisig_address = Multisig::derive_multisig_address(&signers, 0); - // Create 10 proposals from Bob (execute 8, leave 2 active), all expire at block 100 + // Bob creates 10 proposals, all expire at block 100 (at per-signer limit) for i in 0..10 { - let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, - call.clone(), - 100 - )); - if i < 8 { - let hash = calculate_last_proposal_hash(multisig_address, &call); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - hash - )); - } - } - // Bob: 2 Active + 8 Executed = 10 total - - // Create 10 proposals from Charlie (execute 8, leave 2 active) - for i in 10..20 { - let call = make_call(vec![i as u8]); - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(charlie()), - multisig_address, - call.clone(), + make_call(vec![i]), 100 )); - if i < 18 { - let hash = calculate_last_proposal_hash(multisig_address, &call); - assert_ok!(Multisig::approve(RuntimeOrigin::signed(bob()), multisig_address, hash)); - } } - // Charlie: 2 Active + 8 Executed = 10 total - // TOTAL: 4 Active + 16 Executed = 20 (AT LIMIT) + // Bob: 10 Active (at per-signer limit) - // Try to add 21st - should fail + // Bob cannot create more (at limit) assert_noop!( Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, make_call(vec![99]), - 100 + 200 ), - Error::::TooManyProposalsInStorage + Error::::TooManyProposalsPerSigner ); - // Move past expiry + grace period + // Move past expiry System::set_block_number(101); - // Remove Bob's 2 expired active proposals (8-9) - for i in 8..10 { - let call = make_call(vec![i as u8]); - let hash = calculate_proposal_hash_with_nonce(&call, i); - assert_ok!(Multisig::remove_expired( - RuntimeOrigin::signed(bob()), - multisig_address, - hash - )); - } - // Bob: 0 Active + 8 Executed = 8 total - // TOTAL: 2 Active + 16 Executed = 18 - - // Now Bob can add 2 more (his per-signer limit is 10) - for i in 20..22 { - assert_ok!(Multisig::propose( - RuntimeOrigin::signed(bob()), - multisig_address, - make_call(vec![i]), - 200 - )); - } - // Bob: 2 Active + 8 Executed = 10 total - // TOTAL: 4 Active + 16 Executed = 20 (AT LIMIT again) + // Now Bob can create new - propose() auto-cleans expired + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + make_call(vec![99]), + 200 + )); - // Try to add 21st - should still fail - assert_noop!( - Multisig::propose( - RuntimeOrigin::signed(charlie()), - multisig_address, - make_call(vec![98]), - 200 - ), - Error::::TooManyProposalsInStorage - ); + // Verify old proposals were removed + let count = crate::Proposals::::iter_prefix(multisig_address).count(); + assert_eq!(count, 1); // Only the new one remains }); } @@ -1044,43 +962,35 @@ fn per_signer_proposal_limit_enforced() { // MaxActiveProposals = 10, MaxTotalProposalsInStorage = 20 // With 2 signers, each can have max 20/2 = 10 proposals + // Only Active proposals count (Executed/Cancelled auto-removed) - // Bob creates 10 proposals + // Bob creates 10 active proposals (at per-signer limit) for i in 0..10 { - let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), multisig_address, - call.clone(), + make_call(vec![i]), 1000 )); - - // Execute first 5 to stay under active limit - if i < 5 { - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); - assert_ok!(Multisig::approve( - RuntimeOrigin::signed(charlie()), - multisig_address, - proposal_hash - )); - } } - // Now: 5 Active + 5 Executed = 10 total for Bob - // Bob tries to create 11th - should fail with TooManyProposalsPerSigner - let call = make_call(vec![99]); + // Bob at limit - tries to create 11th assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 1000), + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address, + make_call(vec![99]), + 2000 + ), Error::::TooManyProposalsPerSigner ); - // But Charlie can still create (hasn't hit his limit) - let call = make_call(vec![100]); + // But Charlie can still create (independent limit) assert_ok!(Multisig::propose( RuntimeOrigin::signed(charlie()), multisig_address, - call, - 1000 + make_call(vec![100]), + 2000 )); }); } From af3d3f320967d1947ad4b0f45922a47d6920e500 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Fri, 23 Jan 2026 09:20:26 +0800 Subject: [PATCH 23/27] feat: Proposal id - nonce instead of hash --- pallets/multisig/README.md | 70 +++--- pallets/multisig/src/benchmarking.rs | 202 ++++++++++++------ pallets/multisig/src/lib.rs | 119 +++++------ pallets/multisig/src/tests.rs | 90 ++++---- pallets/multisig/src/weights.rs | 308 +++++++++++++++------------ 5 files changed, 432 insertions(+), 357 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index fc5f16ea..0639cf11 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -20,7 +20,7 @@ let call = RuntimeCall::Balances(pallet_balances::Call::transfer { dest: eve, va Multisig::propose(Origin::signed(bob), multisig_addr, call.encode(), expiry_block); // 3. Charlie approves - transaction executes automatically (2/2 threshold reached) -Multisig::approve(Origin::signed(charlie), multisig_addr, proposal_hash); +Multisig::approve(Origin::signed(charlie), multisig_addr, proposal_id); // ✅ Transaction executed! No separate call needed. ``` @@ -68,6 +68,16 @@ 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 expired Active proposals** for this multisig: +- Expired proposals are identified (current_block > expiry) +- Deposits are returned to original proposers +- Storage is cleaned up +- Counters are decremented +- Events are emitted for each removed proposal + +This ensures storage is kept clean and users get their deposits back without manual intervention. + **Economic Costs:** - **ProposalFee**: Non-refundable fee (spam prevention, scaled by signer count) → burned - **ProposalDeposit**: Refundable deposit (storage rent) → returned when proposal removed @@ -80,7 +90,7 @@ to or above the threshold, the transaction will be automatically executed and im **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) -- `proposal_hash: Hash` - Hash of proposal to approve (REQUIRED) +- `proposal_id: u32` - ID (nonce) of the proposal to approve (REQUIRED) **Validation:** - Caller must be a signer @@ -102,7 +112,7 @@ Cancels a proposal and immediately removes it from storage (proposer only). **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) -- `proposal_hash: Hash` - Hash of proposal to cancel (REQUIRED) +- `proposal_id: u32` - ID (nonce) of the proposal to cancel (REQUIRED) **Validation:** - Caller must be the proposer @@ -124,7 +134,7 @@ Manually removes expired proposals from storage. Only signers can call this. **Required Parameters:** - `multisig_address: AccountId` - Target multisig (REQUIRED) -- `proposal_hash: Hash` - Hash of expired proposal (REQUIRED) +- `proposal_id: u32` - ID (nonce) of the expired proposal (REQUIRED) **Validation:** - Caller must be a signer of the multisig @@ -254,8 +264,8 @@ MultisigData { } ``` -### Proposals: DoubleMap -Stores proposal data indexed by (multisig_address, proposal_hash): +### Proposals: DoubleMap +Stores proposal data indexed by (multisig_address, proposal_id): ```rust ProposalData { proposer: AccountId, // Who proposed (receives deposit back) @@ -263,25 +273,11 @@ 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) } ``` -### ExecutedProposals: DoubleMap -**Archive of successfully executed proposals.** Only proposals that were executed are stored here. -Cancelled or expired proposals are NOT archived (only available in events). - -```rust -ExecutedProposalData { - proposer: AccountId, // Who proposed - call: BoundedVec, // The call that was executed - approvers: BoundedVec, // Full list of who approved - executed_at: BlockNumber, // When it was executed - execution_succeeded: bool, // Whether the call succeeded -} -``` - -**Purpose:** Provides permanent on-chain history of all executed multisig transactions. -Can be queried using `Multisig::get_executed_proposal(multisig_address, proposal_hash)`. +**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. @@ -289,12 +285,13 @@ Internal counter for generating unique multisig addresses. Not exposed via API. ## Events - `MultisigCreated { creator, multisig_address, signers, threshold, nonce }` -- `TransactionProposed { multisig_address, proposer, proposal_hash }` -- `ProposalApproved { multisig_address, approver, proposal_hash, approvals_count }` -- `ProposalExecuted { multisig_address, proposal_hash, proposer, call, approvers, result }` -- `ProposalCancelled { multisig_address, proposer, proposal_hash }` -- `ProposalRemoved { multisig_address, proposal_hash, proposer, removed_by }` +- `ProposalCreated { multisig_address, proposer, proposal_id }` +- `ProposalApproved { multisig_address, approver, proposal_id, approvals_count }` +- `ProposalExecuted { multisig_address, proposal_id, proposer, call, approvers, result }` +- `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 }` ## Errors @@ -324,6 +321,23 @@ Internal counter for generating unique multisig addresses. Not exposed via API. ## Important Behavior +### Simple Proposal IDs (Not Hashes) +Proposals are identified by a simple **nonce (u32)** instead of a hash: +- **More efficient:** 4 bytes instead of 32 bytes (Blake2_256 hash) +- **Simpler:** No need to hash `(call, nonce)`, just use nonce directly +- **Better UX:** Sequential IDs (0, 1, 2...) easier to read than random hashes +- **Easier queries:** Can iterate proposals by ID without needing call data + +**Example:** +```rust +propose(...) // → proposal_id: 0 +propose(...) // → proposal_id: 1 +propose(...) // → proposal_id: 2 + +// Approve by ID (not hash) +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 @@ -352,7 +366,7 @@ When a proposal is successfully executed, the pallet emits a comprehensive `Prop ```rust Event::ProposalExecuted { multisig_address: T::AccountId, // The multisig that executed - proposal_hash: T::Hash, // Hash of the proposal + proposal_id: u32, // ID (nonce) of the proposal proposer: T::AccountId, // Who originally proposed it call: Vec, // The encoded call that was executed approvers: Vec, // All accounts that approved diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs index 12c1fb88..40c28a2e 100644 --- a/pallets/multisig/src/benchmarking.rs +++ b/pallets/multisig/src/benchmarking.rs @@ -4,9 +4,8 @@ use super::*; use crate::Pallet as Multisig; use alloc::vec; use frame_benchmarking::{account as benchmark_account, v2::*, BenchmarkError}; -use frame_support::traits::fungible::Mutate; +use frame_support::traits::{fungible::Mutate, ReservableCurrency}; use frame_system::RawOrigin; -use sp_runtime::traits::Hash; const SEED: u32 = 0; @@ -61,33 +60,62 @@ mod benchmarks { #[benchmark] fn propose( c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, + e: Linear<0, { T::MaxTotalProposalsInStorage::get() }>, // expired proposals to cleanup ) -> Result<(), BenchmarkError> { // Setup: Create a multisig first let caller: T::AccountId = whitelisted_caller(); - fund_account::(&caller, BalanceOf2::::from(10000u128)); + 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 signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; let threshold = 2u32; + signers.sort(); - Multisig::::create_multisig( - RawOrigin::Signed(caller.clone()).into(), - signers.clone(), + // 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, // 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); - // 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); + // 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(); - // Create a remark call where the remark itself is c bytes - // We limit c to MaxCallSize - 100 to account for encoding overhead - let system_call = frame_system::Call::::remark { remark: vec![1u8; c as usize] }; + 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 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(); let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); @@ -95,8 +123,9 @@ mod benchmarks { #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); - // Verify proposal was created - assert!(Proposals::::iter_key_prefix(&multisig_address).next().is_some()); + // Verify new proposal was created and expired ones were cleaned + let multisig = Multisigs::::get(&multisig_address).unwrap(); + assert_eq!(multisig.active_proposals, 1); // Only new proposal remains Ok(()) } @@ -130,11 +159,12 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - proposal_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(), }; Multisigs::::insert(&multisig_address, multisig_data); @@ -156,15 +186,14 @@ mod benchmarks { status: ProposalStatus::Active, }; - // Match pallet hashing: hash_of(bounded_call) - let proposal_hash = ::Hashing::hash_of(&proposal_data.call); - Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + let proposal_id = 0u32; + Proposals::::insert(&multisig_address, proposal_id, proposal_data); #[extrinsic_call] - _(RawOrigin::Signed(signer1.clone()), multisig_address.clone(), proposal_hash); + _(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_hash).unwrap(); + let proposal = Proposals::::get(&multisig_address, proposal_id).unwrap(); assert!(proposal.approvals.contains(&signer1)); assert_eq!(proposal.approvals.len(), 2); @@ -197,11 +226,12 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - proposal_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(), }; Multisigs::::insert(&multisig_address, multisig_data); @@ -225,17 +255,15 @@ mod benchmarks { status: ProposalStatus::Active, }; - // Match pallet hashing: hash_of(bounded_call) - let proposal_hash = ::Hashing::hash_of(&proposal_data.call); - Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + 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_hash); + approve(RawOrigin::Signed(signer2.clone()), multisig_address.clone(), proposal_id); - // Verify proposal status was changed to Executed - let executed_proposal = Proposals::::get(&multisig_address, proposal_hash).unwrap(); - assert_eq!(executed_proposal.status, ProposalStatus::Executed); + // Verify proposal was removed from storage (auto-deleted after execution) + assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); Ok(()) } @@ -266,11 +294,12 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - proposal_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(), }; Multisigs::::insert(&multisig_address, multisig_data); @@ -292,16 +321,14 @@ mod benchmarks { status: ProposalStatus::Active, }; - // Match pallet hashing: hash_of(bounded_call) - let proposal_hash = ::Hashing::hash_of(&proposal_data.call); - Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + let proposal_id = 0u32; + Proposals::::insert(&multisig_address, proposal_id, proposal_data); #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_hash); + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_id); - // Verify proposal status was changed to Cancelled - let cancelled_proposal = Proposals::::get(&multisig_address, proposal_hash).unwrap(); - assert_eq!(cancelled_proposal.status, ProposalStatus::Cancelled); + // Verify proposal was removed from storage (auto-deleted after cancellation) + assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); Ok(()) } @@ -330,11 +357,12 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - proposal_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(), }; Multisigs::::insert(&multisig_address, multisig_data); @@ -355,25 +383,27 @@ mod benchmarks { status: ProposalStatus::Active, }; - // Match pallet hashing: hash_of(bounded_call) - let proposal_hash = ::Hashing::hash_of(&proposal_data.call); - Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + let proposal_id = 0u32; + Proposals::::insert(&multisig_address, proposal_id, proposal_data); - // Move past expiry + grace period - frame_system::Pallet::::set_block_number(300u32.into()); + // Move past expiry + frame_system::Pallet::::set_block_number(100u32.into()); - // Call as proposer (caller) since we might still be in grace period + // Call as signer (caller is one of signers) #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_hash); + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_id); // Verify proposal was removed - assert!(!Proposals::::contains_key(&multisig_address, proposal_hash)); + assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); Ok(()) } #[benchmark] - fn claim_deposits() -> Result<(), BenchmarkError> { + fn claim_deposits( + p: Linear<1, { T::MaxTotalProposalsInStorage::get() }>, /* number of expired proposals + * to cleanup */ + ) -> Result<(), BenchmarkError> { // Setup: Create multisig with multiple expired proposals directly in storage let caller: T::AccountId = whitelisted_caller(); fund_account::(&caller, BalanceOf2::::from(100000u128)); @@ -396,18 +426,19 @@ mod benchmarks { signers: bounded_signers, threshold, nonce: 0, - proposal_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: 5, + active_proposals: p, + proposals_per_signer: BoundedBTreeMap::new(), }; Multisigs::::insert(&multisig_address, multisig_data); // Create multiple expired proposals directly in storage let expiry = 10u32.into(); // Already expired - for i in 0..5 { + 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(); @@ -423,21 +454,66 @@ mod benchmarks { status: ProposalStatus::Active, }; - // Match pallet hashing: hash_of(bounded_call) - let proposal_hash = ::Hashing::hash_of(&proposal_data.call); - Proposals::::insert(&multisig_address, proposal_hash, proposal_data); + Proposals::::insert(&multisig_address, i, proposal_data); } - // Move past expiry + grace period - frame_system::Pallet::::set_block_number(300u32.into()); + // Move past expiry + frame_system::Pallet::::set_block_number(100u32.into()); #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), multisig_address.clone()); - // Verify at least some proposals were cleaned up - // Note: claim_deposits only removes proposals past grace period - // Since we set block 300 and expiry was 10, and grace period might vary, - // we just verify the call succeeded + // Verify all expired proposals were cleaned up + assert_eq!(Proposals::::iter_key_prefix(&multisig_address).count(), 0); + + 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 = benchmark_account("signer1", 0, SEED); + let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + + 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, 0); + let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); + let deposit = T::MultisigDeposit::get(); + + // Reserve deposit from caller + T::Currency::reserve(&caller, deposit)?; + + let multisig_data = MultisigDataOf:: { + signers: bounded_signers, + 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); + + // Ensure multisig address has zero balance (required for dissolution) + // Don't fund it at all + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone()); + + // Verify multisig was removed + assert!(!Multisigs::::contains_key(&multisig_address)); + Ok(()) } diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index d8328034..497b8e54 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -252,15 +252,15 @@ pub mod pallet { pub type Multisigs = StorageMap<_, Blake2_128Concat, T::AccountId, MultisigDataOf, OptionQuery>; - /// Proposals indexed by (multisig_address, proposal_hash) + /// Proposals indexed by (multisig_address, proposal_nonce) #[pallet::storage] #[pallet::getter(fn proposals)] pub type Proposals = StorageDoubleMap< _, Blake2_128Concat, T::AccountId, - Blake2_128Concat, - T::Hash, + Twox64Concat, + u32, ProposalDataOf, OptionQuery, >; @@ -278,23 +278,19 @@ pub mod pallet { nonce: u64, }, /// A proposal has been created - ProposalCreated { - multisig_address: T::AccountId, - proposer: T::AccountId, - proposal_hash: T::Hash, - }, + ProposalCreated { multisig_address: T::AccountId, proposer: T::AccountId, proposal_id: u32 }, /// A proposal has been approved by a signer ProposalApproved { multisig_address: T::AccountId, approver: T::AccountId, - proposal_hash: T::Hash, + proposal_id: u32, approvals_count: u32, }, /// A proposal has been executed /// Contains all data needed for indexing by SubSquid ProposalExecuted { multisig_address: T::AccountId, - proposal_hash: T::Hash, + proposal_id: u32, proposer: T::AccountId, call: Vec, approvers: Vec, @@ -304,12 +300,12 @@ pub mod pallet { ProposalCancelled { multisig_address: T::AccountId, proposer: T::AccountId, - proposal_hash: T::Hash, + proposal_id: u32, }, /// Expired proposal was removed from storage ProposalRemoved { multisig_address: T::AccountId, - proposal_hash: T::Hash, + proposal_id: u32, proposer: T::AccountId, removed_by: T::AccountId, }, @@ -496,7 +492,10 @@ pub mod pallet { /// The proposal remains in storage even after execution/cancellation. /// Use `remove_expired()` or `claim_deposits()` after grace period to recover the deposit. #[pallet::call_index(1)] - #[pallet::weight(::WeightInfo::propose(call.len() as u32))] + #[pallet::weight(::WeightInfo::propose( + call.len() as u32, + T::MaxTotalProposalsInStorage::get() + ))] pub fn propose( origin: OriginFor, multisig_address: T::AccountId, @@ -513,13 +512,13 @@ pub mod pallet { // Auto-cleanup expired proposals before creating new one // This ensures storage is managed proactively during normal operation let current_block = frame_system::Pallet::::block_number(); - let expired_proposals: Vec<(T::Hash, T::AccountId, BalanceOf)> = + let expired_proposals: Vec<(u32, T::AccountId, BalanceOf)> = Proposals::::iter_prefix(&multisig_address) - .filter_map(|(hash, proposal)| { + .filter_map(|(id, proposal)| { if proposal.status == ProposalStatus::Active && current_block > proposal.expiry { - Some((hash, proposal.proposer, proposal.deposit)) + Some((id, proposal.proposer, proposal.deposit)) } else { None } @@ -527,8 +526,8 @@ pub mod pallet { .collect(); // Remove expired proposals and return deposits - for (hash, expired_proposer, deposit) in expired_proposals.iter() { - Proposals::::remove(&multisig_address, hash); + for (id, expired_proposer, deposit) in expired_proposals.iter() { + Proposals::::remove(&multisig_address, id); T::Currency::unreserve(expired_proposer, *deposit); // Decrement counters @@ -549,7 +548,7 @@ pub mod pallet { // Emit event for each removed proposal Self::deposit_event(Event::ProposalRemoved { multisig_address: multisig_address.clone(), - proposal_hash: *hash, + proposal_id: *id, proposer: expired_proposer.clone(), removed_by: proposer.clone(), }); @@ -635,8 +634,8 @@ pub mod pallet { let bounded_call: BoundedCallOf = call.try_into().map_err(|_| Error::::CallTooLarge)?; - // Get and increment proposal nonce for unique hash - let proposal_nonce = Multisigs::::mutate(&multisig_address, |maybe_multisig| { + // Get and increment proposal nonce for unique ID + let proposal_id = Multisigs::::mutate(&multisig_address, |maybe_multisig| { if let Some(multisig) = maybe_multisig { let nonce = multisig.proposal_nonce; multisig.proposal_nonce = multisig.proposal_nonce.saturating_add(1); @@ -646,16 +645,6 @@ pub mod pallet { } }); - // Calculate proposal hash including nonce for uniqueness - // This allows multiple proposals with the same call but different nonces - let proposal_hash = T::Hashing::hash_of(&(&bounded_call, proposal_nonce)); - - // Check if proposal already exists (should be impossible with nonce, but safety check) - ensure!( - !Proposals::::contains_key(&multisig_address, proposal_hash), - Error::::ProposalHasDeposit - ); - // Create proposal with proposer as first approval let mut approvals = BoundedApprovalsOf::::default(); let _ = approvals.try_push(proposer.clone()); @@ -669,8 +658,8 @@ pub mod pallet { status: ProposalStatus::Active, }; - // Store proposal - Proposals::::insert(&multisig_address, proposal_hash, proposal); + // 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| { @@ -687,11 +676,7 @@ pub mod pallet { }); // Emit event - Self::deposit_event(Event::ProposalCreated { - multisig_address, - proposer, - proposal_hash, - }); + Self::deposit_event(Event::ProposalCreated { multisig_address, proposer, proposal_id }); Ok(()) } @@ -703,13 +688,13 @@ pub mod pallet { /// /// Parameters: /// - `multisig_address`: The multisig account - /// - `proposal_hash`: Hash of the proposal to approve + /// - `proposal_id`: ID (nonce) of the proposal to approve #[pallet::call_index(2)] #[pallet::weight(::WeightInfo::approve(T::MaxCallSize::get()))] pub fn approve( origin: OriginFor, multisig_address: T::AccountId, - proposal_hash: T::Hash, + proposal_id: u32, ) -> DispatchResult { let approver = ensure_signed(origin)?; @@ -719,7 +704,7 @@ pub mod pallet { ensure!(multisig_data.signers.contains(&approver), Error::::NotASigner); // Get proposal - let mut proposal = Proposals::::get(&multisig_address, proposal_hash) + let mut proposal = Proposals::::get(&multisig_address, proposal_id) .ok_or(Error::::ProposalNotFound)?; // Check if not expired @@ -741,17 +726,17 @@ pub mod pallet { Self::deposit_event(Event::ProposalApproved { multisig_address: multisig_address.clone(), approver, - proposal_hash, + proposal_id, 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_hash, proposal)?; + Self::do_execute(multisig_address, proposal_id, proposal)?; } else { // Not ready yet, just save the proposal - Proposals::::insert(&multisig_address, proposal_hash, proposal); + Proposals::::insert(&multisig_address, proposal_id, proposal); // Update multisig last_activity Multisigs::::mutate(&multisig_address, |maybe_multisig| { @@ -768,18 +753,18 @@ pub mod pallet { /// /// Parameters: /// - `multisig_address`: The multisig account - /// - `proposal_hash`: Hash of the proposal to cancel + /// - `proposal_id`: ID (nonce) of the proposal to cancel #[pallet::call_index(3)] #[pallet::weight(::WeightInfo::cancel(T::MaxCallSize::get()))] pub fn cancel( origin: OriginFor, multisig_address: T::AccountId, - proposal_hash: T::Hash, + proposal_id: u32, ) -> DispatchResult { let canceller = ensure_signed(origin)?; // Get proposal - let proposal = Proposals::::get(&multisig_address, proposal_hash) + let proposal = Proposals::::get(&multisig_address, proposal_id) .ok_or(Error::::ProposalNotFound)?; // Check if caller is the proposer @@ -789,7 +774,7 @@ pub mod pallet { ensure!(proposal.status == ProposalStatus::Active, Error::::ProposalNotActive); // Remove proposal from storage immediately - Proposals::::remove(&multisig_address, proposal_hash); + Proposals::::remove(&multisig_address, proposal_id); // Return deposit to proposer immediately T::Currency::unreserve(&proposal.proposer, proposal.deposit); @@ -813,7 +798,7 @@ pub mod pallet { Self::deposit_event(Event::ProposalCancelled { multisig_address, proposer: canceller, - proposal_hash, + proposal_id, }); Ok(()) @@ -832,7 +817,7 @@ pub mod pallet { pub fn remove_expired( origin: OriginFor, multisig_address: T::AccountId, - proposal_hash: T::Hash, + proposal_id: u32, ) -> DispatchResult { let caller = ensure_signed(origin)?; @@ -842,7 +827,7 @@ pub mod pallet { ensure!(multisig_data.signers.contains(&caller), Error::::NotASigner); // Get proposal - let proposal = Proposals::::get(&multisig_address, proposal_hash) + let proposal = Proposals::::get(&multisig_address, proposal_id) .ok_or(Error::::ProposalNotFound)?; // Only Active proposals can be manually removed (Executed/Cancelled already @@ -857,7 +842,7 @@ pub mod pallet { T::Currency::unreserve(&proposal.proposer, proposal.deposit); // Remove proposal from storage - Proposals::::remove(&multisig_address, proposal_hash); + Proposals::::remove(&multisig_address, proposal_id); // Decrement counters Multisigs::::mutate(&multisig_address, |maybe_multisig| { @@ -877,7 +862,7 @@ pub mod pallet { // Emit event Self::deposit_event(Event::ProposalRemoved { multisig_address, - proposal_hash, + proposal_id, proposer: proposal.proposer.clone(), removed_by: caller, }); @@ -896,7 +881,9 @@ pub mod pallet { /// /// Returns all proposal deposits to the proposer in a single transaction. #[pallet::call_index(5)] - #[pallet::weight(::WeightInfo::claim_deposits())] + #[pallet::weight(::WeightInfo::claim_deposits( + T::MaxTotalProposalsInStorage::get() + ))] pub fn claim_deposits( origin: OriginFor, multisig_address: T::AccountId, @@ -910,7 +897,7 @@ pub mod pallet { // Iterate through all proposals for this multisig // Only Active+Expired proposals exist (Executed/Cancelled are auto-removed) - let proposals_to_remove: Vec<(T::Hash, ProposalDataOf)> = + let proposals_to_remove: Vec<(u32, ProposalDataOf)> = Proposals::::iter_prefix(&multisig_address) .filter(|(_, proposal)| { // Only proposals where caller is proposer @@ -925,13 +912,13 @@ pub mod pallet { .collect(); // Remove proposals and return deposits - for (hash, proposal) in proposals_to_remove { + for (id, proposal) in proposals_to_remove { // Return deposit T::Currency::unreserve(&proposal.proposer, proposal.deposit); total_returned = total_returned.saturating_add(proposal.deposit); // Remove from storage - Proposals::::remove(&multisig_address, hash); + Proposals::::remove(&multisig_address, id); removed_count = removed_count.saturating_add(1); // Decrement counters (all are Active since Executed/Cancelled auto-removed) @@ -954,7 +941,7 @@ pub mod pallet { // Emit event for each removed proposal Self::deposit_event(Event::ProposalRemoved { multisig_address: multisig_address.clone(), - proposal_hash: hash, + proposal_id: id, proposer: caller.clone(), removed_by: caller.clone(), }); @@ -1025,12 +1012,6 @@ pub mod pallet { } impl Pallet { - /// Calculate proposal hash from call and proposal nonce - /// This ensures each proposal has a unique hash even if the call is identical - pub fn calculate_proposal_hash(call: &[u8], proposal_nonce: u32) -> T::Hash { - T::Hashing::hash_of(&(call, proposal_nonce)) - } - /// 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. @@ -1064,9 +1045,7 @@ pub mod pallet { /// Internal function to execute a proposal /// Called automatically from `approve()` when threshold is reached /// - /// Marks the proposal as executed. The proposal remains in storage and - /// the deposit is NOT returned immediately. Use `remove_expired()` or - /// `claim_deposits()` to remove the proposal and recover deposit. + /// Removes the proposal immediately and returns deposit. /// /// This function is private and cannot be called from outside the pallet /// @@ -1074,7 +1053,7 @@ pub mod pallet { /// Storage is updated BEFORE dispatching the call. fn do_execute( multisig_address: T::AccountId, - proposal_hash: T::Hash, + proposal_id: u32, proposal: ProposalDataOf, ) -> DispatchResult { // CHECKS: Decode the call (validation) @@ -1083,7 +1062,7 @@ pub mod pallet { // EFFECTS: Remove proposal from storage BEFORE external interaction (reentrancy // protection) - Proposals::::remove(&multisig_address, proposal_hash); + Proposals::::remove(&multisig_address, proposal_id); // EFFECTS: Return deposit to proposer BEFORE external interaction T::Currency::unreserve(&proposal.proposer, proposal.deposit); @@ -1112,7 +1091,7 @@ pub mod pallet { // Emit event with all execution details for SubSquid indexing Self::deposit_event(Event::ProposalExecuted { multisig_address, - proposal_hash, + proposal_id, proposer: proposal.proposer, call: proposal.call.to_vec(), approvers: proposal.approvals.to_vec(), diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index fc6b5af3..256e83db 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}; +use crate::{mock::*, Error, Event, GlobalNonce, Multisigs}; use codec::Encode; use frame_support::{assert_noop, assert_ok}; /// Helper function to get Alice's account ID @@ -29,25 +29,11 @@ fn make_call(remark: Vec) -> Vec { call.encode() } -/// Helper function to calculate proposal hash for testing -/// Note: This calculates hash for the LAST proposal (uses current proposal_nonce - 1) -/// because propose() increments nonce before calculating hash -fn calculate_last_proposal_hash( - multisig_address: u64, - call: &[u8], -) -> ::Hash { +/// Helper function to get the ID of the last proposal created +/// Returns the current proposal_nonce - 1 (last used ID) +fn get_last_proposal_id(multisig_address: u64) -> u32 { let multisig = Multisigs::::get(multisig_address).expect("Multisig should exist"); - // The last proposal used (proposal_nonce - 1) because propose() increments it - let nonce_used = multisig.proposal_nonce.saturating_sub(1); - Multisig::calculate_proposal_hash(call, nonce_used) -} - -/// Helper function to calculate proposal hash for a specific nonce -fn calculate_proposal_hash_with_nonce( - call: &[u8], - nonce: u32, -) -> ::Hash { - Multisig::calculate_proposal_hash(call, nonce) + multisig.proposal_nonce.saturating_sub(1) } // ==================== MULTISIG CREATION TESTS ==================== @@ -218,9 +204,9 @@ fn propose_works() { // Fee is burned (reduces total issuance) // Check event - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + let proposal_id = get_last_proposal_id(multisig_address); System::assert_last_event( - Event::ProposalCreated { multisig_address, proposer, proposal_hash }.into(), + Event::ProposalCreated { multisig_address, proposer, proposal_id }.into(), ); }); } @@ -264,13 +250,13 @@ fn approve_works() { 1000 )); - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + let proposal_id = get_last_proposal_id(multisig_address); // Charlie approves (now 2/3) assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, - proposal_hash + proposal_id )); // Check event @@ -278,14 +264,14 @@ fn approve_works() { Event::ProposalApproved { multisig_address, approver: charlie(), - proposal_hash, + proposal_id, approvals_count: 2, } .into(), ); // Proposal should still exist (not executed yet) - assert!(crate::Proposals::::contains_key(multisig_address, proposal_hash)); + assert!(crate::Proposals::::contains_key(multisig_address, proposal_id)); }); } @@ -308,17 +294,17 @@ fn approve_auto_executes_when_threshold_reached() { 1000 )); - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + let proposal_id = get_last_proposal_id(multisig_address); // Charlie approves - threshold reached (2/2), auto-executes and removes assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, - proposal_hash + proposal_id )); // Check that proposal was executed and immediately removed from storage - assert!(crate::Proposals::::get(multisig_address, proposal_hash).is_none()); + 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 @@ -327,7 +313,7 @@ fn approve_auto_executes_when_threshold_reached() { System::assert_has_event( Event::ProposalExecuted { multisig_address, - proposal_hash, + proposal_id, proposer: bob(), call: call.clone(), approvers: vec![bob(), charlie()], @@ -360,24 +346,24 @@ fn cancel_works() { 1000 )); - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + let proposal_id = get_last_proposal_id(multisig_address); // Cancel the proposal - immediately removes and returns deposit assert_ok!(Multisig::cancel( RuntimeOrigin::signed(proposer), multisig_address, - proposal_hash + proposal_id )); // Proposal should be immediately removed from storage - assert!(crate::Proposals::::get(multisig_address, proposal_hash).is_none()); + assert!(crate::Proposals::::get(multisig_address, proposal_id).is_none()); // Deposit should be returned immediately assert_eq!(Balances::reserved_balance(proposer), 0); // Check event System::assert_last_event( - Event::ProposalCancelled { multisig_address, proposer, proposal_hash }.into(), + Event::ProposalCancelled { multisig_address, proposer, proposal_id }.into(), ); }); } @@ -401,18 +387,18 @@ fn cancel_fails_if_already_executed() { 1000 )); - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + let proposal_id = get_last_proposal_id(multisig_address); // Approve to execute (auto-executes and removes proposal) assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, - proposal_hash + proposal_id )); // Try to cancel executed proposal (already removed, so ProposalNotFound) assert_noop!( - Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, proposal_hash), + Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, proposal_id), Error::::ProposalNotFound ); }); @@ -440,7 +426,7 @@ fn remove_expired_works_after_grace_period() { expiry )); - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + let proposal_id = get_last_proposal_id(multisig_address); // Move past expiry + grace period (100 blocks) System::set_block_number(expiry + 101); @@ -449,11 +435,11 @@ fn remove_expired_works_after_grace_period() { assert_ok!(Multisig::remove_expired( RuntimeOrigin::signed(charlie()), multisig_address, - proposal_hash + proposal_id )); // Proposal should be gone - assert!(!crate::Proposals::::contains_key(multisig_address, proposal_hash)); + assert!(!crate::Proposals::::contains_key(multisig_address, proposal_id)); // Deposit should be returned to proposer assert_eq!(Balances::reserved_balance(bob()), 0); @@ -479,17 +465,17 @@ fn executed_proposals_auto_removed() { 1000 )); - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + let proposal_id = get_last_proposal_id(multisig_address); // Execute - should auto-remove proposal and return deposit assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, - proposal_hash + proposal_id )); // Proposal should be immediately removed - assert!(crate::Proposals::::get(multisig_address, proposal_hash).is_none()); + assert!(crate::Proposals::::get(multisig_address, proposal_id).is_none()); // Deposit should be immediately returned assert_eq!(Balances::reserved_balance(bob()), 0); @@ -499,7 +485,7 @@ fn executed_proposals_auto_removed() { Multisig::remove_expired( RuntimeOrigin::signed(charlie()), multisig_address, - proposal_hash + proposal_id ), Error::::ProposalNotFound ); @@ -526,18 +512,14 @@ fn remove_expired_fails_for_non_signer() { expiry )); - let proposal_hash = calculate_last_proposal_hash(multisig_address, &call); + let proposal_id = get_last_proposal_id(multisig_address); // Move past expiry System::set_block_number(expiry + 1); // Dave is not a signer, should fail assert_noop!( - Multisig::remove_expired( - RuntimeOrigin::signed(dave()), - multisig_address, - proposal_hash - ), + Multisig::remove_expired(RuntimeOrigin::signed(dave()), multisig_address, proposal_id), Error::::NotASigner ); @@ -545,7 +527,7 @@ fn remove_expired_fails_for_non_signer() { assert_ok!(Multisig::remove_expired( RuntimeOrigin::signed(charlie()), multisig_address, - proposal_hash + proposal_id )); }); } @@ -708,15 +690,15 @@ fn only_active_proposals_remain_in_storage() { )); if i < 5 { - let hash = calculate_last_proposal_hash(multisig_address, &call); + let id = get_last_proposal_id(multisig_address); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), multisig_address, - hash + id )); } else if i == 5 { - let hash = calculate_last_proposal_hash(multisig_address, &call); - assert_ok!(Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, hash)); + let id = get_last_proposal_id(multisig_address); + assert_ok!(Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, id)); } } // Bob now has 4 Active in storage (i=6,7,8,9), 5 executed + 1 cancelled were removed diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 9a3b90ce..d620d3aa 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-21, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-01-23, 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` @@ -33,8 +33,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)] @@ -48,12 +48,12 @@ use core::marker::PhantomData; /// Weight functions needed for `pallet_multisig`. pub trait WeightInfo { fn create_multisig() -> Weight; - fn propose(c: u32, ) -> Weight; + fn propose(c: u32, e: u32, ) -> Weight; fn approve(c: u32, ) -> Weight; fn approve_and_execute(c: u32, ) -> Weight; fn cancel(c: u32, ) -> Weight; fn remove_expired() -> Weight; - fn claim_deposits() -> Weight; + fn claim_deposits(p: u32, ) -> Weight; fn dissolve_multisig() -> Weight; } @@ -63,119 +63,131 @@ 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(3322), added: 5797, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `456` - // Estimated: `6787` - // Minimum execution time: 193_000_000 picoseconds. - Weight::from_parts(202_000_000, 6787) - .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().writes(3_u64)) + // Measured: `152` + // Estimated: `10389` + // Minimum execution time: 190_000_000 picoseconds. + Weight::from_parts(191_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(3322), added: 5797, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:2 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, 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]`. - fn propose(c: u32, ) -> Weight { + /// The range of component `e` is `[0, 200]`. + fn propose(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `841` - // Estimated: `33126` - // Minimum execution time: 163_000_000 picoseconds. - Weight::from_parts(75_630_635, 33126) - // Standard Error: 324 - .saturating_add(Weight::from_parts(159_235, 0).saturating_mul(c.into())) - .saturating_add(T::DbWeight::get().reads(4_u64)) - .saturating_add(T::DbWeight::get().writes(3_u64)) + // Measured: `458 + e * (215 ±0)` + // Estimated: `17022 + e * (16032 ±0)` + // Minimum execution time: 40_000_000 picoseconds. + Weight::from_parts(3_495_135, 17022) + // Standard Error: 273 + .saturating_add(Weight::from_parts(3_193, 0).saturating_mul(c.into())) + // Standard Error: 13_844 + .saturating_add(Weight::from_parts(14_158_106, 0).saturating_mul(e.into())) + .saturating_add(T::DbWeight::get().reads(2_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(3322), added: 5797, mode: `MaxEncodedLen`) + /// 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(13593), added: 16068, mode: `MaxEncodedLen`) + /// 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: `797 + c * (1 ±0)` - // Estimated: `17058` - // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(13_927_745, 17058) - // Standard Error: 7 - .saturating_add(Weight::from_parts(345, 0).saturating_mul(c.into())) + // Measured: `766 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(13_805_399, 17022) + // Standard Error: 5 + .saturating_add(Weight::from_parts(361, 0).saturating_mul(c.into())) .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(3322), added: 5797, mode: `MaxEncodedLen`) + /// 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(13593), added: 16068, mode: `MaxEncodedLen`) + /// 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: `765 + c * (1 ±0)` - // Estimated: `17058` - // Minimum execution time: 15_000_000 picoseconds. - Weight::from_parts(17_252_468, 17058) - // Standard Error: 9 - .saturating_add(Weight::from_parts(753, 0).saturating_mul(c.into())) + // Measured: `734 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 23_000_000 picoseconds. + Weight::from_parts(23_950_420, 17022) + // Standard Error: 5 + .saturating_add(Weight::from_parts(503, 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(13593), added: 16068, mode: `MaxEncodedLen`) + /// 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(3322), added: 5797, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `765 + c * (1 ±0)` - // Estimated: `17058` - // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(12_138_163, 17058) - // Standard Error: 9 - .saturating_add(Weight::from_parts(463, 0).saturating_mul(c.into())) + // Measured: `734 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(18_406_758, 17022) + // Standard Error: 28 + .saturating_add(Weight::from_parts(501, 0).saturating_mul(c.into())) .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(3322), added: 5797, mode: `MaxEncodedLen`) + /// 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(13593), added: 16068, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `795` - // Estimated: `17058` - // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(21_000_000, 17058) + // Measured: `764` + // Estimated: `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::Proposals` (r:6 w:5) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) + /// 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(3322), added: 5797, mode: `MaxEncodedLen`) - fn claim_deposits() -> Weight { + /// 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 Size summary in bytes: - // Measured: `1907` - // Estimated: `97398` - // Minimum execution time: 71_000_000 picoseconds. - Weight::from_parts(79_000_000, 97398) - .saturating_add(T::DbWeight::get().reads(7_u64)) - .saturating_add(T::DbWeight::get().writes(6_u64)) + // Measured: `625 + p * (237 ±0)` + // Estimated: `17022 + p * (16032 ±0)` + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) + // Standard Error: 12_149 + .saturating_add(Weight::from_parts(13_915_582, 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())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, 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`) fn dissolve_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `500` - // Estimated: `6787` - // Minimum execution time: 15_000_000 picoseconds. - Weight::from_parts(18_000_000, 6787) - .saturating_add(T::DbWeight::get().reads(1_u64)) + // Measured: `538` + // Estimated: `17022` + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_000_000, 17022) + .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } } @@ -185,119 +197,131 @@ 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(3322), added: 5797, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) fn create_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `456` - // Estimated: `6787` - // Minimum execution time: 193_000_000 picoseconds. - Weight::from_parts(202_000_000, 6787) - .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().writes(3_u64)) + // Measured: `152` + // Estimated: `10389` + // Minimum execution time: 190_000_000 picoseconds. + Weight::from_parts(191_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(3322), added: 5797, mode: `MaxEncodedLen`) - /// Storage: `Multisig::Proposals` (r:2 w:1) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, 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]`. - fn propose(c: u32, ) -> Weight { + /// The range of component `e` is `[0, 200]`. + fn propose(c: u32, e: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `841` - // Estimated: `33126` - // Minimum execution time: 163_000_000 picoseconds. - Weight::from_parts(75_630_635, 33126) - // Standard Error: 324 - .saturating_add(Weight::from_parts(159_235, 0).saturating_mul(c.into())) - .saturating_add(RocksDbWeight::get().reads(4_u64)) - .saturating_add(RocksDbWeight::get().writes(3_u64)) + // Measured: `458 + e * (215 ±0)` + // Estimated: `17022 + e * (16032 ±0)` + // Minimum execution time: 40_000_000 picoseconds. + Weight::from_parts(3_495_135, 17022) + // Standard Error: 273 + .saturating_add(Weight::from_parts(3_193, 0).saturating_mul(c.into())) + // Standard Error: 13_844 + .saturating_add(Weight::from_parts(14_158_106, 0).saturating_mul(e.into())) + .saturating_add(RocksDbWeight::get().reads(2_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(3322), added: 5797, mode: `MaxEncodedLen`) + /// 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(13593), added: 16068, mode: `MaxEncodedLen`) + /// 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: `797 + c * (1 ±0)` - // Estimated: `17058` - // Minimum execution time: 12_000_000 picoseconds. - Weight::from_parts(13_927_745, 17058) - // Standard Error: 7 - .saturating_add(Weight::from_parts(345, 0).saturating_mul(c.into())) + // Measured: `766 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 13_000_000 picoseconds. + Weight::from_parts(13_805_399, 17022) + // Standard Error: 5 + .saturating_add(Weight::from_parts(361, 0).saturating_mul(c.into())) .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(3322), added: 5797, mode: `MaxEncodedLen`) + /// 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(13593), added: 16068, mode: `MaxEncodedLen`) + /// 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: `765 + c * (1 ±0)` - // Estimated: `17058` - // Minimum execution time: 15_000_000 picoseconds. - Weight::from_parts(17_252_468, 17058) - // Standard Error: 9 - .saturating_add(Weight::from_parts(753, 0).saturating_mul(c.into())) + // Measured: `734 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 23_000_000 picoseconds. + Weight::from_parts(23_950_420, 17022) + // Standard Error: 5 + .saturating_add(Weight::from_parts(503, 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(13593), added: 16068, mode: `MaxEncodedLen`) + /// 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(3322), added: 5797, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) /// The range of component `c` is `[0, 10140]`. fn cancel(c: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `765 + c * (1 ±0)` - // Estimated: `17058` - // Minimum execution time: 11_000_000 picoseconds. - Weight::from_parts(12_138_163, 17058) - // Standard Error: 9 - .saturating_add(Weight::from_parts(463, 0).saturating_mul(c.into())) + // Measured: `734 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 18_000_000 picoseconds. + Weight::from_parts(18_406_758, 17022) + // Standard Error: 28 + .saturating_add(Weight::from_parts(501, 0).saturating_mul(c.into())) .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(3322), added: 5797, mode: `MaxEncodedLen`) + /// 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(13593), added: 16068, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) fn remove_expired() -> Weight { // Proof Size summary in bytes: - // Measured: `795` - // Estimated: `17058` - // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(21_000_000, 17058) + // Measured: `764` + // Estimated: `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::Proposals` (r:6 w:5) - /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13593), added: 16068, mode: `MaxEncodedLen`) + /// 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(3322), added: 5797, mode: `MaxEncodedLen`) - fn claim_deposits() -> Weight { + /// 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 Size summary in bytes: - // Measured: `1907` - // Estimated: `97398` - // Minimum execution time: 71_000_000 picoseconds. - Weight::from_parts(79_000_000, 97398) - .saturating_add(RocksDbWeight::get().reads(7_u64)) - .saturating_add(RocksDbWeight::get().writes(6_u64)) + // Measured: `625 + p * (237 ±0)` + // Estimated: `17022 + p * (16032 ±0)` + // Minimum execution time: 22_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) + // Standard Error: 12_149 + .saturating_add(Weight::from_parts(13_915_582, 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())) } /// Storage: `Multisig::Multisigs` (r:1 w:1) - /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(3322), added: 5797, mode: `MaxEncodedLen`) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, 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`) fn dissolve_multisig() -> Weight { // Proof Size summary in bytes: - // Measured: `500` - // Estimated: `6787` - // Minimum execution time: 15_000_000 picoseconds. - Weight::from_parts(18_000_000, 6787) - .saturating_add(RocksDbWeight::get().reads(1_u64)) + // Measured: `538` + // Estimated: `17022` + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(21_000_000, 17022) + .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } } From 5f787ccd279b8fc349ef47a13bb894df978d2909 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Fri, 23 Jan 2026 11:22:40 +0800 Subject: [PATCH 24/27] feat: Calls - production whitelist --- pallets/multisig/README.md | 56 +++++++++++++++++++++ pallets/multisig/src/lib.rs | 18 ++++++- pallets/multisig/src/mock.rs | 1 + pallets/multisig/src/weights.rs | 88 ++++++++++++++++----------------- runtime/src/configs/mod.rs | 34 +++++++++++-- 5 files changed, 146 insertions(+), 51 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index 0639cf11..91151303 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -174,6 +174,60 @@ Batch cleanup operation to recover all expired proposal deposits. **Auto-Cleanup:** When anyone calls `propose()`, all expired proposals are automatically removed first, making this function often unnecessary. +## Call Filtering & Whitelisting + +The pallet supports optional call whitelisting through the `CallFilter` configuration parameter. This allows restricting which calls can be proposed in multisigs. + +### Configuration + +```rust +// Whitelist only specific calls (e.g., transfers only) +pub struct MultisigCallFilter; +impl Contains for MultisigCallFilter { + fn contains(call: &RuntimeCall) -> bool { + matches!(call, + RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { .. }) | + RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { .. }) + ) + } +} + +impl pallet_multisig::Config for Runtime { + type CallFilter = MultisigCallFilter; + // ... +} +``` + +### Validation + +When a proposal is created via `propose()`, the call is: +1. **Decoded** from `Vec` to `RuntimeCall` +2. **Checked** against `CallFilter::contains()` +3. **Rejected** with `Error::CallNotWhitelisted` if not in whitelist + +**Important:** +- Filter applies to **ALL multisigs** in the runtime (global setting) +- Changes require **runtime upgrade** (no per-multisig customization) +- Custom filter defines which calls can be proposed +- Unauthorized calls rejected with `CallNotWhitelisted` error + +### Use Cases + +**Payroll Multisig (transfers only):** +```rust +// Only allow keep_alive transfers to prevent account deletion +matches!(call, RuntimeCall::Balances(Call::transfer_keep_alive { .. })) +``` + +**Treasury Multisig (governance + transfers):** +```rust +matches!(call, + RuntimeCall::Balances(Call::transfer_keep_alive { .. }) | + RuntimeCall::Scheduler(Call::schedule { .. }) | // Time-locked ops + RuntimeCall::Democracy(Call::veto { .. }) // Emergency stops +) +``` + ## Economic Model ### Fees (Non-refundable, burned) @@ -312,6 +366,7 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `ProposalExpired` - Proposal deadline passed (for approve) - `CallTooLarge` - Encoded call exceeds MaxCallSize - `InvalidCall` - Call decoding failed during execution +- `CallNotWhitelisted` - Call is not allowed by CallFilter (whitelist rejection) - `InsufficientBalance` - Not enough funds for fee/deposit - `TooManyActiveProposals` - Multisig has MaxActiveProposals open proposals - `TooManyProposalsInStorage` - Multisig has MaxTotalProposalsInStorage total proposals (cleanup required to create new) @@ -440,6 +495,7 @@ This event structure is optimized for indexing by SubSquid and similar indexers: ```rust impl pallet_multisig::Config for Runtime { type RuntimeCall = RuntimeCall; + type CallFilter = MultisigCallFilter; // Custom whitelist (see Call Filtering section) type Currency = Balances; // Storage limits (prevent unbounded growth) diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 497b8e54..e3424e26 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -34,7 +34,10 @@ mod tests; pub mod weights; use codec::{Decode, Encode, MaxEncodedLen}; -use frame_support::{traits::Get, BoundedBTreeMap, BoundedVec}; +use frame_support::{ + traits::{Contains, Get}, + BoundedBTreeMap, BoundedVec, +}; use scale_info::TypeInfo; use sp_runtime::RuntimeDebug; @@ -148,6 +151,10 @@ pub mod pallet { + From> + codec::Decode; + /// Filter for which calls can be proposed in multisigs + /// Use `Everything` to allow all calls, or custom filter for whitelist + type CallFilter: Contains<::RuntimeCall>; + /// Currency type for handling deposits type Currency: Currency + ReservableCurrency; @@ -375,6 +382,8 @@ pub mod pallet { ProposalNotExpired, /// Proposal is not active (already executed or cancelled) ProposalNotActive, + /// Call is not whitelisted for high-security multisig + CallNotWhitelisted, /// Cannot dissolve multisig with existing proposals (clear them first) ProposalsExist, /// Multisig account must have zero balance before dissolution @@ -591,6 +600,13 @@ pub mod pallet { // Check call size ensure!(call.len() as u32 <= T::MaxCallSize::get(), Error::::CallTooLarge); + // Validate call against whitelist (CallFilter) + // Note: We decode as frame_system::Config::RuntimeCall which is the same as + // Config::RuntimeCall + let decoded_call = <::RuntimeCall>::decode(&mut &call[..]) + .map_err(|_| Error::::InvalidCall)?; + ensure!(T::CallFilter::contains(&decoded_call), Error::::CallNotWhitelisted); + // Validate expiry is in the future ensure!(expiry > current_block, Error::::ExpiryInPast); diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index 6ccfeaa2..e63716f0 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -101,6 +101,7 @@ parameter_types! { impl pallet_multisig::Config for Test { type RuntimeCall = RuntimeCall; + type CallFilter = Everything; // Allow all calls in tests type Currency = Balances; type MaxSigners = MaxSignersParam; type MaxActiveProposals = MaxActiveProposalsParam; diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index d620d3aa..a92baf7b 100644 --- a/pallets/multisig/src/weights.rs +++ b/pallets/multisig/src/weights.rs @@ -68,8 +68,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(191_000_000, 10389) + // Minimum execution time: 191_000_000 picoseconds. + Weight::from_parts(192_000_000, 10389) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -79,16 +79,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: `458 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 40_000_000 picoseconds. - Weight::from_parts(3_495_135, 17022) - // Standard Error: 273 - .saturating_add(Weight::from_parts(3_193, 0).saturating_mul(c.into())) - // Standard Error: 13_844 - .saturating_add(Weight::from_parts(14_158_106, 0).saturating_mul(e.into())) + // Minimum execution time: 42_000_000 picoseconds. + Weight::from_parts(49_667_534, 17022) + // Standard Error: 6_629 + .saturating_add(Weight::from_parts(14_048_036, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -105,9 +103,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `766 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(13_805_399, 17022) - // Standard Error: 5 - .saturating_add(Weight::from_parts(361, 0).saturating_mul(c.into())) + Weight::from_parts(14_333_639, 17022) + // Standard Error: 12 + .saturating_add(Weight::from_parts(331, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -121,9 +119,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `734 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(23_950_420, 17022) - // Standard Error: 5 - .saturating_add(Weight::from_parts(503, 0).saturating_mul(c.into())) + Weight::from_parts(24_139_136, 17022) + // Standard Error: 6 + .saturating_add(Weight::from_parts(516, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -137,9 +135,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `734 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(18_406_758, 17022) - // Standard Error: 28 - .saturating_add(Weight::from_parts(501, 0).saturating_mul(c.into())) + Weight::from_parts(18_926_960, 17022) + // Standard Error: 5 + .saturating_add(Weight::from_parts(229, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -166,9 +164,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) - // Standard Error: 12_149 - .saturating_add(Weight::from_parts(13_915_582, 0).saturating_mul(p.into())) + Weight::from_parts(5_417_803, 17022) + // Standard Error: 5_062 + .saturating_add(Weight::from_parts(13_630_315, 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)) @@ -185,8 +183,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `538` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(20_000_000, 17022) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -202,8 +200,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(191_000_000, 10389) + // Minimum execution time: 191_000_000 picoseconds. + Weight::from_parts(192_000_000, 10389) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -213,16 +211,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: `458 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 40_000_000 picoseconds. - Weight::from_parts(3_495_135, 17022) - // Standard Error: 273 - .saturating_add(Weight::from_parts(3_193, 0).saturating_mul(c.into())) - // Standard Error: 13_844 - .saturating_add(Weight::from_parts(14_158_106, 0).saturating_mul(e.into())) + // Minimum execution time: 42_000_000 picoseconds. + Weight::from_parts(49_667_534, 17022) + // Standard Error: 6_629 + .saturating_add(Weight::from_parts(14_048_036, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -239,9 +235,9 @@ impl WeightInfo for () { // Measured: `766 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(13_805_399, 17022) - // Standard Error: 5 - .saturating_add(Weight::from_parts(361, 0).saturating_mul(c.into())) + Weight::from_parts(14_333_639, 17022) + // Standard Error: 12 + .saturating_add(Weight::from_parts(331, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -255,9 +251,9 @@ impl WeightInfo for () { // Measured: `734 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(23_950_420, 17022) - // Standard Error: 5 - .saturating_add(Weight::from_parts(503, 0).saturating_mul(c.into())) + Weight::from_parts(24_139_136, 17022) + // Standard Error: 6 + .saturating_add(Weight::from_parts(516, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -271,9 +267,9 @@ impl WeightInfo for () { // Measured: `734 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(18_406_758, 17022) - // Standard Error: 28 - .saturating_add(Weight::from_parts(501, 0).saturating_mul(c.into())) + Weight::from_parts(18_926_960, 17022) + // Standard Error: 5 + .saturating_add(Weight::from_parts(229, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -300,9 +296,9 @@ impl WeightInfo for () { // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) - // Standard Error: 12_149 - .saturating_add(Weight::from_parts(13_915_582, 0).saturating_mul(p.into())) + Weight::from_parts(5_417_803, 17022) + // Standard Error: 5_062 + .saturating_add(Weight::from_parts(13_630_315, 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)) @@ -319,8 +315,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `538` // Estimated: `17022` - // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(20_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 2215b9ec..042b6942 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -55,10 +55,7 @@ use pallet_ranked_collective::Linear; use pallet_transaction_payment::{ConstFeeMultiplier, FungibleAdapter, Multiplier}; use qp_poseidon::PoseidonHasher; use qp_scheduler::BlockNumberOrTimestamp; -use sp_runtime::{ - traits::{AccountIdConversion, One}, - FixedU128, Perbill, Permill, -}; +use sp_runtime::{traits::One, FixedU128, Perbill, Permill}; use sp_version::RuntimeVersion; // Local module imports @@ -579,8 +576,37 @@ 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 +/// Production: Only balance transfers are allowed +/// Benchmarking: system::remark is also allowed for weight generation +pub struct MultisigCallFilter; +impl frame_support::traits::Contains for MultisigCallFilter { + fn contains(call: &RuntimeCall) -> bool { + #[cfg(feature = "runtime-benchmarks")] + { + // Benchmarks use system::remark with varying sizes to generate universal weights + matches!( + call, + RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { .. }) | + RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { .. }) | + RuntimeCall::System(frame_system::Call::remark { .. }) + ) + } + #[cfg(not(feature = "runtime-benchmarks"))] + { + // Production: strict whitelist + matches!( + call, + RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { .. }) | + RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { .. }) + ) + } + } +} + impl pallet_multisig::Config for Runtime { type RuntimeCall = RuntimeCall; + type CallFilter = MultisigCallFilter; type Currency = Balances; type MaxSigners = MaxSigners; type MaxActiveProposals = MaxActiveProposals; From 6b89f28e323a14140fb11e4f35b8e6e9ea1f7484 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Sat, 24 Jan 2026 09:08:40 +0800 Subject: [PATCH 25/27] feat: Remove call whitelisting --- pallets/multisig/README.md | 41 +------ pallets/multisig/src/lib.rs | 204 ++++++++++++++------------------ pallets/multisig/src/mock.rs | 1 - pallets/multisig/src/weights.rs | 98 +++++++-------- runtime/src/configs/mod.rs | 28 ----- 5 files changed, 139 insertions(+), 233 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index 91151303..95fdae25 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -174,44 +174,7 @@ Batch cleanup operation to recover all expired proposal deposits. **Auto-Cleanup:** When anyone calls `propose()`, all expired proposals are automatically removed first, making this function often unnecessary. -## Call Filtering & Whitelisting - -The pallet supports optional call whitelisting through the `CallFilter` configuration parameter. This allows restricting which calls can be proposed in multisigs. - -### Configuration - -```rust -// Whitelist only specific calls (e.g., transfers only) -pub struct MultisigCallFilter; -impl Contains for MultisigCallFilter { - fn contains(call: &RuntimeCall) -> bool { - matches!(call, - RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { .. }) | - RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { .. }) - ) - } -} - -impl pallet_multisig::Config for Runtime { - type CallFilter = MultisigCallFilter; - // ... -} -``` - -### Validation - -When a proposal is created via `propose()`, the call is: -1. **Decoded** from `Vec` to `RuntimeCall` -2. **Checked** against `CallFilter::contains()` -3. **Rejected** with `Error::CallNotWhitelisted` if not in whitelist - -**Important:** -- Filter applies to **ALL multisigs** in the runtime (global setting) -- Changes require **runtime upgrade** (no per-multisig customization) -- Custom filter defines which calls can be proposed -- Unauthorized calls rejected with `CallNotWhitelisted` error - -### Use Cases +## Use Cases **Payroll Multisig (transfers only):** ```rust @@ -366,7 +329,6 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `ProposalExpired` - Proposal deadline passed (for approve) - `CallTooLarge` - Encoded call exceeds MaxCallSize - `InvalidCall` - Call decoding failed during execution -- `CallNotWhitelisted` - Call is not allowed by CallFilter (whitelist rejection) - `InsufficientBalance` - Not enough funds for fee/deposit - `TooManyActiveProposals` - Multisig has MaxActiveProposals open proposals - `TooManyProposalsInStorage` - Multisig has MaxTotalProposalsInStorage total proposals (cleanup required to create new) @@ -495,7 +457,6 @@ This event structure is optimized for indexing by SubSquid and similar indexers: ```rust impl pallet_multisig::Config for Runtime { type RuntimeCall = RuntimeCall; - type CallFilter = MultisigCallFilter; // Custom whitelist (see Call Filtering section) type Currency = Balances; // Storage limits (prevent unbounded growth) diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index e3424e26..1bed69e3 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -34,10 +34,7 @@ mod tests; pub mod weights; use codec::{Decode, Encode, MaxEncodedLen}; -use frame_support::{ - traits::{Contains, Get}, - BoundedBTreeMap, BoundedVec, -}; +use frame_support::{traits::Get, BoundedBTreeMap, BoundedVec}; use scale_info::TypeInfo; use sp_runtime::RuntimeDebug; @@ -151,10 +148,6 @@ pub mod pallet { + From> + codec::Decode; - /// Filter for which calls can be proposed in multisigs - /// Use `Everything` to allow all calls, or custom filter for whitelist - type CallFilter: Contains<::RuntimeCall>; - /// Currency type for handling deposits type Currency: Currency + ReservableCurrency; @@ -382,8 +375,6 @@ pub mod pallet { ProposalNotExpired, /// Proposal is not active (already executed or cancelled) ProposalNotActive, - /// Call is not whitelisted for high-security multisig - CallNotWhitelisted, /// Cannot dissolve multisig with existing proposals (clear them first) ProposalsExist, /// Multisig account must have zero balance before dissolution @@ -536,23 +527,12 @@ pub mod pallet { // Remove expired proposals and return deposits for (id, expired_proposer, deposit) in expired_proposals.iter() { - Proposals::::remove(&multisig_address, id); - T::Currency::unreserve(expired_proposer, *deposit); - - // Decrement counters - Multisigs::::mutate(&multisig_address, |maybe_multisig| { - if let Some(multisig) = maybe_multisig { - multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - - if let Some(count) = multisig.proposals_per_signer.get_mut(expired_proposer) - { - *count = count.saturating_sub(1); - if *count == 0 { - multisig.proposals_per_signer.remove(expired_proposer); - } - } - } - }); + Self::remove_proposal_and_return_deposit( + &multisig_address, + *id, + expired_proposer, + *deposit, + ); // Emit event for each removed proposal Self::deposit_event(Event::ProposalRemoved { @@ -600,13 +580,6 @@ pub mod pallet { // Check call size ensure!(call.len() as u32 <= T::MaxCallSize::get(), Error::::CallTooLarge); - // Validate call against whitelist (CallFilter) - // Note: We decode as frame_system::Config::RuntimeCall which is the same as - // Config::RuntimeCall - let decoded_call = <::RuntimeCall>::decode(&mut &call[..]) - .map_err(|_| Error::::InvalidCall)?; - ensure!(T::CallFilter::contains(&decoded_call), Error::::CallNotWhitelisted); - // Validate expiry is in the future ensure!(expiry > current_block, Error::::ExpiryInPast); @@ -715,9 +688,7 @@ pub mod pallet { let approver = ensure_signed(origin)?; // Check if approver is a signer - let multisig_data = - Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; - ensure!(multisig_data.signers.contains(&approver), Error::::NotASigner); + let multisig_data = Self::ensure_is_signer(&multisig_address, &approver)?; // Get proposal let mut proposal = Proposals::::get(&multisig_address, proposal_id) @@ -789,26 +760,13 @@ pub mod pallet { // Check if proposal is still active ensure!(proposal.status == ProposalStatus::Active, Error::::ProposalNotActive); - // Remove proposal from storage immediately - Proposals::::remove(&multisig_address, proposal_id); - - // Return deposit to proposer immediately - T::Currency::unreserve(&proposal.proposer, proposal.deposit); - - // Decrement counters - 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(&proposal.proposer) { - *count = count.saturating_sub(1); - if *count == 0 { - multisig.proposals_per_signer.remove(&proposal.proposer); - } - } - } - }); + // Remove proposal from storage and return deposit immediately + Self::remove_proposal_and_return_deposit( + &multisig_address, + proposal_id, + &proposal.proposer, + proposal.deposit, + ); // Emit event Self::deposit_event(Event::ProposalCancelled { @@ -838,9 +796,7 @@ pub mod pallet { let caller = ensure_signed(origin)?; // Verify caller is a signer - let multisig_data = - Multisigs::::get(&multisig_address).ok_or(Error::::MultisigNotFound)?; - ensure!(multisig_data.signers.contains(&caller), Error::::NotASigner); + let _multisig_data = Self::ensure_is_signer(&multisig_address, &caller)?; // Get proposal let proposal = Proposals::::get(&multisig_address, proposal_id) @@ -854,26 +810,13 @@ pub mod pallet { let current_block = frame_system::Pallet::::block_number(); ensure!(current_block > proposal.expiry, Error::::ProposalNotExpired); - // Return deposit to proposer - T::Currency::unreserve(&proposal.proposer, proposal.deposit); - - // Remove proposal from storage - Proposals::::remove(&multisig_address, proposal_id); - - // Decrement counters - 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(&proposal.proposer) { - *count = count.saturating_sub(1); - if *count == 0 { - multisig.proposals_per_signer.remove(&proposal.proposer); - } - } - } - }); + // Remove proposal from storage and return deposit + Self::remove_proposal_and_return_deposit( + &multisig_address, + proposal_id, + &proposal.proposer, + proposal.deposit, + ); // Emit event Self::deposit_event(Event::ProposalRemoved { @@ -929,30 +872,16 @@ pub mod pallet { // Remove proposals and return deposits for (id, proposal) in proposals_to_remove { - // Return deposit - T::Currency::unreserve(&proposal.proposer, proposal.deposit); total_returned = total_returned.saturating_add(proposal.deposit); - - // Remove from storage - Proposals::::remove(&multisig_address, id); removed_count = removed_count.saturating_add(1); - // Decrement counters (all are Active since Executed/Cancelled auto-removed) - 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(&proposal.proposer) - { - *count = count.saturating_sub(1); - if *count == 0 { - multisig.proposals_per_signer.remove(&proposal.proposer); - } - } - } - }); + // 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 { @@ -1058,6 +987,54 @@ pub mod pallet { } } + /// Ensure account is a signer, otherwise return error + /// Returns multisig data if successful + fn ensure_is_signer( + multisig_address: &T::AccountId, + account: &T::AccountId, + ) -> Result, DispatchError> { + let multisig_data = + Multisigs::::get(multisig_address).ok_or(Error::::MultisigNotFound)?; + ensure!(multisig_data.signers.contains(account), Error::::NotASigner); + Ok(multisig_data) + } + + /// 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); + } + } + } + }); + } + + /// Remove a proposal from storage and return deposit to proposer + /// Used for cleanup operations + fn remove_proposal_and_return_deposit( + multisig_address: &T::AccountId, + proposal_id: u32, + proposer: &T::AccountId, + deposit: BalanceOf, + ) { + // Remove from storage + Proposals::::remove(multisig_address, proposal_id); + + // Return deposit to proposer + T::Currency::unreserve(proposer, deposit); + + // Decrement counters + Self::decrement_proposal_counters(multisig_address, proposer); + } + /// Internal function to execute a proposal /// Called automatically from `approve()` when threshold is reached /// @@ -1076,26 +1053,19 @@ pub mod pallet { let call = ::RuntimeCall::decode(&mut &proposal.call[..]) .map_err(|_| Error::::InvalidCall)?; - // EFFECTS: Remove proposal from storage BEFORE external interaction (reentrancy - // protection) - Proposals::::remove(&multisig_address, proposal_id); - - // EFFECTS: Return deposit to proposer BEFORE external interaction - T::Currency::unreserve(&proposal.proposer, proposal.deposit); + // EFFECTS: Remove proposal from storage and return deposit BEFORE external interaction + // (reentrancy protection) + Self::remove_proposal_and_return_deposit( + &multisig_address, + proposal_id, + &proposal.proposer, + proposal.deposit, + ); - // EFFECTS: Update multisig counters BEFORE external interaction + // 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(); - multisig.active_proposals = multisig.active_proposals.saturating_sub(1); - - // Decrement per-signer counter - if let Some(count) = multisig.proposals_per_signer.get_mut(&proposal.proposer) { - *count = count.saturating_sub(1); - if *count == 0 { - multisig.proposals_per_signer.remove(&proposal.proposer); - } - } } }); diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index e63716f0..6ccfeaa2 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -101,7 +101,6 @@ parameter_types! { impl pallet_multisig::Config for Test { type RuntimeCall = RuntimeCall; - type CallFilter = Everything; // Allow all calls in tests type Currency = Balances; type MaxSigners = MaxSignersParam; type MaxActiveProposals = MaxActiveProposalsParam; diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index a92baf7b..52471931 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-23, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-01-24, 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,8 +68,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 191_000_000 picoseconds. - Weight::from_parts(192_000_000, 10389) + // Minimum execution time: 190_000_000 picoseconds. + Weight::from_parts(195_000_000, 10389) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -79,14 +79,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: `458 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 42_000_000 picoseconds. - Weight::from_parts(49_667_534, 17022) - // Standard Error: 6_629 - .saturating_add(Weight::from_parts(14_048_036, 0).saturating_mul(e.into())) + // Minimum execution time: 41_000_000 picoseconds. + Weight::from_parts(41_000_000, 17022) + // Standard Error: 214 + .saturating_add(Weight::from_parts(3_021, 0).saturating_mul(c.into())) + // Standard Error: 10_908 + .saturating_add(Weight::from_parts(13_941_381, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -102,10 +104,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `766 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(14_333_639, 17022) - // Standard Error: 12 - .saturating_add(Weight::from_parts(331, 0).saturating_mul(c.into())) + // Minimum execution time: 14_000_000 picoseconds. + Weight::from_parts(15_161_664, 17022) + // Standard Error: 27 + .saturating_add(Weight::from_parts(396, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -118,10 +120,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `734 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(24_139_136, 17022) - // Standard Error: 6 - .saturating_add(Weight::from_parts(516, 0).saturating_mul(c.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(27_171_358, 17022) + // Standard Error: 35 + .saturating_add(Weight::from_parts(528, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -135,9 +137,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `734 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(18_926_960, 17022) - // Standard Error: 5 - .saturating_add(Weight::from_parts(229, 0).saturating_mul(c.into())) + Weight::from_parts(18_990_756, 17022) + // Standard Error: 11 + .saturating_add(Weight::from_parts(253, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -164,9 +166,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(5_417_803, 17022) - // Standard Error: 5_062 - .saturating_add(Weight::from_parts(13_630_315, 0).saturating_mul(p.into())) + Weight::from_parts(7_138_812, 17022) + // Standard Error: 21_568 + .saturating_add(Weight::from_parts(13_690_513, 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)) @@ -183,8 +185,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `538` // Estimated: `17022` - // Minimum execution time: 19_000_000 picoseconds. - Weight::from_parts(20_000_000, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -200,8 +202,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 191_000_000 picoseconds. - Weight::from_parts(192_000_000, 10389) + // Minimum execution time: 190_000_000 picoseconds. + Weight::from_parts(195_000_000, 10389) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -211,14 +213,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: `458 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 42_000_000 picoseconds. - Weight::from_parts(49_667_534, 17022) - // Standard Error: 6_629 - .saturating_add(Weight::from_parts(14_048_036, 0).saturating_mul(e.into())) + // Minimum execution time: 41_000_000 picoseconds. + Weight::from_parts(41_000_000, 17022) + // Standard Error: 214 + .saturating_add(Weight::from_parts(3_021, 0).saturating_mul(c.into())) + // Standard Error: 10_908 + .saturating_add(Weight::from_parts(13_941_381, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -234,10 +238,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `766 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 13_000_000 picoseconds. - Weight::from_parts(14_333_639, 17022) - // Standard Error: 12 - .saturating_add(Weight::from_parts(331, 0).saturating_mul(c.into())) + // Minimum execution time: 14_000_000 picoseconds. + Weight::from_parts(15_161_664, 17022) + // Standard Error: 27 + .saturating_add(Weight::from_parts(396, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -250,10 +254,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `734 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 23_000_000 picoseconds. - Weight::from_parts(24_139_136, 17022) - // Standard Error: 6 - .saturating_add(Weight::from_parts(516, 0).saturating_mul(c.into())) + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(27_171_358, 17022) + // Standard Error: 35 + .saturating_add(Weight::from_parts(528, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -267,9 +271,9 @@ impl WeightInfo for () { // Measured: `734 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(18_926_960, 17022) - // Standard Error: 5 - .saturating_add(Weight::from_parts(229, 0).saturating_mul(c.into())) + Weight::from_parts(18_990_756, 17022) + // Standard Error: 11 + .saturating_add(Weight::from_parts(253, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -296,9 +300,9 @@ impl WeightInfo for () { // Measured: `625 + p * (237 ±0)` // Estimated: `17022 + p * (16032 ±0)` // Minimum execution time: 22_000_000 picoseconds. - Weight::from_parts(5_417_803, 17022) - // Standard Error: 5_062 - .saturating_add(Weight::from_parts(13_630_315, 0).saturating_mul(p.into())) + Weight::from_parts(7_138_812, 17022) + // Standard Error: 21_568 + .saturating_add(Weight::from_parts(13_690_513, 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)) @@ -315,8 +319,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `538` // Estimated: `17022` - // Minimum execution time: 19_000_000 picoseconds. - Weight::from_parts(20_000_000, 17022) + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(22_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 042b6942..13181712 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -577,36 +577,8 @@ parameter_types! { } /// Whitelist for calls that can be proposed in multisigs -/// Production: Only balance transfers are allowed -/// Benchmarking: system::remark is also allowed for weight generation -pub struct MultisigCallFilter; -impl frame_support::traits::Contains for MultisigCallFilter { - fn contains(call: &RuntimeCall) -> bool { - #[cfg(feature = "runtime-benchmarks")] - { - // Benchmarks use system::remark with varying sizes to generate universal weights - matches!( - call, - RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { .. }) | - RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { .. }) | - RuntimeCall::System(frame_system::Call::remark { .. }) - ) - } - #[cfg(not(feature = "runtime-benchmarks"))] - { - // Production: strict whitelist - matches!( - call, - RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { .. }) | - RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { .. }) - ) - } - } -} - impl pallet_multisig::Config for Runtime { type RuntimeCall = RuntimeCall; - type CallFilter = MultisigCallFilter; type Currency = Balances; type MaxSigners = MaxSigners; type MaxActiveProposals = MaxActiveProposals; From e856d6ad89c1f017375bf27ba6db9d07affba050 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Mon, 26 Jan 2026 08:01:45 +0800 Subject: [PATCH 26/27] fix: Test fix after balances pallet update --- pallets/multisig/src/lib.rs | 28 ++- pallets/multisig/src/mock.rs | 23 ++- pallets/multisig/src/tests.rs | 373 +++++++++++++++++++++++----------- 3 files changed, 296 insertions(+), 128 deletions(-) diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 1bed69e3..d60b6a01 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -124,7 +124,9 @@ pub mod pallet { use super::*; use codec::Encode; use frame_support::{ - dispatch::{DispatchResult, GetDispatchInfo, PostDispatchInfo}, + dispatch::{ + DispatchResult, DispatchResultWithPostInfo, GetDispatchInfo, Pays, PostDispatchInfo, + }, pallet_prelude::*, traits::{Currency, ReservableCurrency}, PalletId, @@ -678,13 +680,16 @@ pub mod pallet { /// Parameters: /// - `multisig_address`: The multisig account /// - `proposal_id`: ID (nonce) of the proposal to approve + /// + /// Weight: Charges for MAX call size, but refunds based on actual call size #[pallet::call_index(2)] #[pallet::weight(::WeightInfo::approve(T::MaxCallSize::get()))] + #[allow(clippy::useless_conversion)] pub fn approve( origin: OriginFor, multisig_address: T::AccountId, proposal_id: u32, - ) -> DispatchResult { + ) -> DispatchResultWithPostInfo { let approver = ensure_signed(origin)?; // Check if approver is a signer @@ -694,6 +699,10 @@ pub mod pallet { let mut proposal = Proposals::::get(&multisig_address, proposal_id) .ok_or(Error::::ProposalNotFound)?; + // Calculate actual weight based on real call size (for refund) + let actual_call_size = proposal.call.len() as u32; + let actual_weight = ::WeightInfo::approve(actual_call_size); + // Check if not expired let current_block = frame_system::Pallet::::block_number(); ensure!(current_block <= proposal.expiry, Error::::ProposalExpired); @@ -733,7 +742,8 @@ pub mod pallet { }); } - Ok(()) + // Return actual weight (refund overpayment) + Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } /// Cancel a proposed transaction (only by proposer) @@ -741,19 +751,26 @@ pub mod pallet { /// Parameters: /// - `multisig_address`: The multisig account /// - `proposal_id`: ID (nonce) of the proposal to cancel + /// + /// Weight: Charges for MAX call size, but refunds based on actual call size #[pallet::call_index(3)] #[pallet::weight(::WeightInfo::cancel(T::MaxCallSize::get()))] + #[allow(clippy::useless_conversion)] pub fn cancel( origin: OriginFor, multisig_address: T::AccountId, proposal_id: u32, - ) -> DispatchResult { + ) -> DispatchResultWithPostInfo { let canceller = ensure_signed(origin)?; // Get proposal let proposal = Proposals::::get(&multisig_address, proposal_id) .ok_or(Error::::ProposalNotFound)?; + // Calculate actual weight based on real call size (for refund) + let actual_call_size = proposal.call.len() as u32; + let actual_weight = ::WeightInfo::cancel(actual_call_size); + // Check if caller is the proposer ensure!(canceller == proposal.proposer, Error::::NotProposer); @@ -775,7 +792,8 @@ pub mod pallet { proposal_id, }); - Ok(()) + // Return actual weight (refund overpayment) + Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) } /// Remove expired proposals and return deposits to proposers diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index 6ccfeaa2..b9ec85d8 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -6,7 +6,7 @@ use frame_support::{ traits::{ConstU32, Everything}, PalletId, }; -use sp_core::H256; +use sp_core::{crypto::AccountId32, H256}; use sp_runtime::{ traits::{BlakeTwo256, IdentityLookup}, BuildStorage, Permill, @@ -41,7 +41,7 @@ impl frame_system::Config for Test { type Nonce = u64; type Hash = H256; type Hashing = BlakeTwo256; - type AccountId = u64; + type AccountId = AccountId32; type Lookup = IdentityLookup; type BlockHashCount = BlockHashCount; type Version = (); @@ -67,6 +67,7 @@ parameter_types! { pub const MaxLocks: u32 = 50; pub const MaxReserves: u32 = 50; pub const MaxFreezes: u32 = 50; + pub const MintingAccount: AccountId32 = AccountId32::new([99u8; 32]); } impl pallet_balances::Config for Test { @@ -83,6 +84,7 @@ impl pallet_balances::Config for Test { type FreezeIdentifier = (); type MaxFreezes = MaxFreezes; type DoneSlashHandler = (); + type MintingAccount = MintingAccount; } parameter_types! { @@ -116,17 +118,24 @@ impl pallet_multisig::Config for Test { type WeightInfo = (); } +// 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) +} + // 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![ - (1, 100000), // Alice - (2, 200000), // Bob - (3, 300000), // Charlie - (4, 400000), // Dave - (5, 500000), // Eve + (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 ], } .assimilate_storage(&mut t) diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index 256e83db..d1760d26 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -3,24 +3,26 @@ use crate::{mock::*, Error, Event, GlobalNonce, Multisigs}; use codec::Encode; use frame_support::{assert_noop, assert_ok}; +use sp_core::crypto::AccountId32; + /// Helper function to get Alice's account ID -fn alice() -> u64 { - 1 +fn alice() -> AccountId32 { + account_id(1) } /// Helper function to get Bob's account ID -fn bob() -> u64 { - 2 +fn bob() -> AccountId32 { + account_id(2) } /// Helper function to get Charlie's account ID -fn charlie() -> u64 { - 3 +fn charlie() -> AccountId32 { + account_id(3) } /// Helper function to get Dave's account ID -fn dave() -> u64 { - 4 +fn dave() -> AccountId32 { + account_id(4) } /// Helper function to create a simple encoded call @@ -31,7 +33,7 @@ fn make_call(remark: Vec) -> Vec { /// Helper function to get the ID of the last proposal created /// Returns the current proposal_nonce - 1 (last used ID) -fn get_last_proposal_id(multisig_address: u64) -> u32 { +fn get_last_proposal_id(multisig_address: &AccountId32) -> u32 { let multisig = Multisigs::::get(multisig_address).expect("Multisig should exist"); multisig.proposal_nonce.saturating_sub(1) } @@ -50,21 +52,21 @@ fn create_multisig_works() { let threshold = 2; // Get initial balance - let initial_balance = Balances::free_balance(creator); + let initial_balance = Balances::free_balance(creator.clone()); let fee = 1000; // MultisigFeeParam let deposit = 500; // MultisigDepositParam // Create multisig assert_ok!(Multisig::create_multisig( - RuntimeOrigin::signed(creator), + RuntimeOrigin::signed(creator.clone()), signers.clone(), threshold, )); // Check balances // Deposit is reserved, fee is burned - assert_eq!(Balances::reserved_balance(creator), deposit); - assert_eq!(Balances::free_balance(creator), initial_balance - fee - deposit); + assert_eq!(Balances::reserved_balance(creator.clone()), deposit); + assert_eq!(Balances::free_balance(creator.clone()), initial_balance - fee - deposit); // Check that multisig was created let global_nonce = GlobalNonce::::get(); @@ -74,12 +76,12 @@ fn create_multisig_works() { let multisig_address = Multisig::derive_multisig_address(&signers, 0); // Check storage - let multisig_data = Multisigs::::get(multisig_address).unwrap(); + 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); + assert_eq!(multisig_data.creator, creator.clone()); assert_eq!(multisig_data.deposit, deposit); // Check that event was emitted @@ -98,7 +100,7 @@ fn create_multisig_fails_with_threshold_zero() { let threshold = 0; assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), + Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), Error::::ThresholdZero ); }); @@ -112,7 +114,7 @@ fn create_multisig_fails_with_empty_signers() { let threshold = 1; assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), + Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), Error::::NotEnoughSigners ); }); @@ -126,7 +128,7 @@ fn create_multisig_fails_with_threshold_too_high() { let threshold = 3; // More than number of signers assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), + Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), Error::::ThresholdTooHigh ); }); @@ -140,7 +142,7 @@ fn create_multisig_fails_with_duplicate_signers() { let threshold = 2; assert_noop!( - Multisig::create_multisig(RuntimeOrigin::signed(creator), signers, threshold,), + Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), Error::::DuplicateSigner ); }); @@ -153,8 +155,16 @@ fn create_multiple_multisigs_increments_nonce() { let signers1 = vec![bob(), charlie()]; let signers2 = vec![bob(), dave()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers1.clone(), 2)); - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers2.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers1.clone(), + 2 + )); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers2.clone(), + 2 + )); // Check both multisigs exist let multisig1 = Multisig::derive_multisig_address(&signers1, 0); @@ -174,7 +184,11 @@ fn propose_works() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); @@ -183,28 +197,28 @@ fn propose_works() { let call = make_call(vec![1, 2, 3]); let expiry = 1000; - let initial_balance = Balances::free_balance(proposer); + let initial_balance = Balances::free_balance(proposer.clone()); let proposal_deposit = 100; // ProposalDepositParam (Changed in mock) // Fee calculation: Base(1000) + (Base(1000) * 1% * 2 signers) = 1000 + 20 = 1020 let proposal_fee = 1020; assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), - multisig_address, + RuntimeOrigin::signed(proposer.clone()), + multisig_address.clone(), call.clone(), expiry )); // Check balances - deposit reserved, fee sent to treasury - assert_eq!(Balances::reserved_balance(proposer), proposal_deposit); + assert_eq!(Balances::reserved_balance(proposer.clone()), proposal_deposit); assert_eq!( - Balances::free_balance(proposer), + Balances::free_balance(proposer.clone()), initial_balance - proposal_deposit - proposal_fee ); // Fee is burned (reduces total issuance) // Check event - let proposal_id = get_last_proposal_id(multisig_address); + let proposal_id = get_last_proposal_id(&multisig_address); System::assert_last_event( Event::ProposalCreated { multisig_address, proposer, proposal_id }.into(), ); @@ -216,14 +230,18 @@ fn propose_fails_if_not_signer() { new_test_ext().execute_with(|| { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); // Try to propose as non-signer let call = make_call(vec![1, 2, 3]); assert_noop!( - Multisig::propose(RuntimeOrigin::signed(dave()), multisig_address, call, 1000), + Multisig::propose(RuntimeOrigin::signed(dave()), multisig_address.clone(), call, 1000), Error::::NotASigner ); }); @@ -238,31 +256,35 @@ fn approve_works() { let creator = alice(); let signers = vec![bob(), charlie(), dave()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 3)); // Need 3 approvals + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 3 + )); // Need 3 approvals let multisig_address = Multisig::derive_multisig_address(&signers, 0); let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), call.clone(), 1000 )); - let proposal_id = get_last_proposal_id(multisig_address); + let proposal_id = get_last_proposal_id(&multisig_address); // Charlie approves (now 2/3) assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), proposal_id )); // Check event System::assert_last_event( Event::ProposalApproved { - multisig_address, + multisig_address: multisig_address.clone(), approver: charlie(), proposal_id, approvals_count: 2, @@ -271,7 +293,7 @@ fn approve_works() { ); // Proposal should still exist (not executed yet) - assert!(crate::Proposals::::contains_key(multisig_address, proposal_id)); + assert!(crate::Proposals::::contains_key(&multisig_address, proposal_id)); }); } @@ -282,29 +304,33 @@ fn approve_auto_executes_when_threshold_reached() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), call.clone(), 1000 )); - let proposal_id = get_last_proposal_id(multisig_address); + let proposal_id = get_last_proposal_id(&multisig_address); // Charlie approves - threshold reached (2/2), auto-executes and removes assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), proposal_id )); // Check that proposal was executed and immediately removed from storage - assert!(crate::Proposals::::get(multisig_address, proposal_id).is_none()); + 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 @@ -333,33 +359,37 @@ fn cancel_works() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); let proposer = bob(); let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), - multisig_address, + RuntimeOrigin::signed(proposer.clone()), + multisig_address.clone(), call.clone(), 1000 )); - let proposal_id = get_last_proposal_id(multisig_address); + let proposal_id = get_last_proposal_id(&multisig_address); // Cancel the proposal - immediately removes and returns deposit assert_ok!(Multisig::cancel( - RuntimeOrigin::signed(proposer), - multisig_address, + RuntimeOrigin::signed(proposer.clone()), + multisig_address.clone(), proposal_id )); // Proposal should be immediately removed from storage - assert!(crate::Proposals::::get(multisig_address, proposal_id).is_none()); + assert!(crate::Proposals::::get(&multisig_address, proposal_id).is_none()); // Deposit should be returned immediately - assert_eq!(Balances::reserved_balance(proposer), 0); + assert_eq!(Balances::reserved_balance(proposer.clone()), 0); // Check event System::assert_last_event( @@ -375,30 +405,34 @@ fn cancel_fails_if_already_executed() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), call.clone(), 1000 )); - let proposal_id = get_last_proposal_id(multisig_address); + let proposal_id = get_last_proposal_id(&multisig_address); // Approve to execute (auto-executes and removes proposal) assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), proposal_id )); // Try to cancel executed proposal (already removed, so ProposalNotFound) assert_noop!( - Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, proposal_id), + Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address.clone(), proposal_id), Error::::ProposalNotFound ); }); @@ -413,7 +447,11 @@ fn remove_expired_works_after_grace_period() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); @@ -421,12 +459,12 @@ fn remove_expired_works_after_grace_period() { let expiry = 100; assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), call.clone(), expiry )); - let proposal_id = get_last_proposal_id(multisig_address); + let proposal_id = get_last_proposal_id(&multisig_address); // Move past expiry + grace period (100 blocks) System::set_block_number(expiry + 101); @@ -434,12 +472,12 @@ fn remove_expired_works_after_grace_period() { // Any signer can remove after grace period (charlie is a signer) assert_ok!(Multisig::remove_expired( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), proposal_id )); // Proposal should be gone - assert!(!crate::Proposals::::contains_key(multisig_address, proposal_id)); + assert!(!crate::Proposals::::contains_key(&multisig_address, proposal_id)); // Deposit should be returned to proposer assert_eq!(Balances::reserved_balance(bob()), 0); @@ -453,29 +491,33 @@ fn executed_proposals_auto_removed() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); let call = make_call(vec![1, 2, 3]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), call.clone(), 1000 )); - let proposal_id = get_last_proposal_id(multisig_address); + let proposal_id = get_last_proposal_id(&multisig_address); // Execute - should auto-remove proposal and return deposit assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), proposal_id )); // Proposal should be immediately removed - assert!(crate::Proposals::::get(multisig_address, proposal_id).is_none()); + assert!(crate::Proposals::::get(&multisig_address, proposal_id).is_none()); // Deposit should be immediately returned assert_eq!(Balances::reserved_balance(bob()), 0); @@ -484,7 +526,7 @@ fn executed_proposals_auto_removed() { assert_noop!( Multisig::remove_expired( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), proposal_id ), Error::::ProposalNotFound @@ -499,7 +541,11 @@ fn remove_expired_fails_for_non_signer() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); @@ -507,26 +553,30 @@ fn remove_expired_fails_for_non_signer() { let expiry = 1000; assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), call.clone(), expiry )); - let proposal_id = get_last_proposal_id(multisig_address); + let proposal_id = get_last_proposal_id(&multisig_address); // Move past expiry System::set_block_number(expiry + 1); // Dave is not a signer, should fail assert_noop!( - Multisig::remove_expired(RuntimeOrigin::signed(dave()), multisig_address, proposal_id), + Multisig::remove_expired( + RuntimeOrigin::signed(dave()), + multisig_address.clone(), + proposal_id + ), Error::::NotASigner ); // But charlie (who is a signer) can do it assert_ok!(Multisig::remove_expired( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), proposal_id )); }); @@ -539,7 +589,11 @@ fn claim_deposits_works() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); @@ -548,7 +602,7 @@ fn claim_deposits_works() { let call = make_call(vec![i as u8; 32]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), call, 100 )); @@ -561,7 +615,10 @@ fn claim_deposits_works() { System::set_block_number(201); // Bob claims all deposits at once - assert_ok!(Multisig::claim_deposits(RuntimeOrigin::signed(bob()), multisig_address)); + assert_ok!(Multisig::claim_deposits( + RuntimeOrigin::signed(bob()), + multisig_address.clone() + )); // All deposits returned assert_eq!(Balances::reserved_balance(bob()), 0); @@ -628,7 +685,11 @@ fn too_many_proposals_in_storage_fails() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); // MaxTotal = 20, 2 signers = 10 each @@ -638,7 +699,7 @@ fn too_many_proposals_in_storage_fails() { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), call.clone(), 1000 )); @@ -650,7 +711,7 @@ fn too_many_proposals_in_storage_fails() { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), call.clone(), 1000 )); @@ -661,7 +722,7 @@ fn too_many_proposals_in_storage_fails() { // Try to add 21st - should fail on total limit let call = make_call(vec![99]); assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 2000), + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address.clone(), call, 2000), Error::::TooManyProposalsInStorage ); }); @@ -674,7 +735,11 @@ fn only_active_proposals_remain_in_storage() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); // Test that only Active proposals remain in storage (Executed/Cancelled auto-removed) @@ -684,21 +749,25 @@ fn only_active_proposals_remain_in_storage() { let call = make_call(vec![i as u8]); assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), call.clone(), 1000 )); if i < 5 { - let id = get_last_proposal_id(multisig_address); + let id = get_last_proposal_id(&multisig_address); assert_ok!(Multisig::approve( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), id )); } else if i == 5 { - let id = get_last_proposal_id(multisig_address); - assert_ok!(Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address, id)); + let id = get_last_proposal_id(&multisig_address); + assert_ok!(Multisig::cancel( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + id + )); } } // Bob now has 4 Active in storage (i=6,7,8,9), 5 executed + 1 cancelled were removed @@ -707,7 +776,7 @@ fn only_active_proposals_remain_in_storage() { for i in 10..16 { assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), make_call(vec![i]), 2000 )); @@ -718,7 +787,7 @@ fn only_active_proposals_remain_in_storage() { assert_noop!( Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), make_call(vec![99]), 3000 ), @@ -734,14 +803,18 @@ fn auto_cleanup_allows_new_proposals() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); // Bob creates 10 proposals, all expire at block 100 (at per-signer limit) for i in 0..10 { assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), make_call(vec![i]), 100 )); @@ -752,7 +825,7 @@ fn auto_cleanup_allows_new_proposals() { assert_noop!( Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), make_call(vec![99]), 200 ), @@ -765,13 +838,13 @@ fn auto_cleanup_allows_new_proposals() { // Now Bob can create new - propose() auto-cleans expired assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), make_call(vec![99]), 200 )); // Verify old proposals were removed - let count = crate::Proposals::::iter_prefix(multisig_address).count(); + let count = crate::Proposals::::iter_prefix(&multisig_address).count(); assert_eq!(count, 1); // Only the new one remains }); } @@ -783,7 +856,11 @@ fn propose_fails_with_expiry_in_past() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); @@ -791,18 +868,33 @@ fn propose_fails_with_expiry_in_past() { // Try to create proposal with expiry in the past (< current_block) assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call.clone(), 50), + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call.clone(), + 50 + ), Error::::ExpiryInPast ); // Try with expiry equal to current block (not > current_block) assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call.clone(), 100), + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call.clone(), + 100 + ), Error::::ExpiryInPast ); // Valid: expiry in the future - assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 101)); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call, + 101 + )); }); } @@ -813,7 +905,11 @@ fn propose_fails_with_expiry_too_far() { let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); @@ -825,13 +921,23 @@ fn propose_fails_with_expiry_too_far() { // Try to create proposal with expiry too far in the future assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call.clone(), 10101), + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call.clone(), + 10101 + ), Error::::ExpiryTooFar ); // Try with expiry way beyond the limit assert_noop!( - Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call.clone(), 20000), + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call.clone(), + 20000 + ), Error::::ExpiryTooFar ); @@ -846,7 +952,12 @@ fn propose_fails_with_expiry_too_far() { // Move to next block and try again System::set_block_number(101); // Now max allowed = 101 + 10000 = 10101 - assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 10101)); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call, + 10101 + )); }); } @@ -858,16 +969,20 @@ fn propose_charges_correct_fee_with_signer_factor() { let creator = alice(); // 3 Signers: Bob, Charlie, Dave let signers = vec![bob(), charlie(), dave()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); let proposer = bob(); let call = make_call(vec![1, 2, 3]); - let initial_balance = Balances::free_balance(proposer); + let initial_balance = Balances::free_balance(proposer.clone()); assert_ok!(Multisig::propose( - RuntimeOrigin::signed(proposer), + RuntimeOrigin::signed(proposer.clone()), multisig_address, call, 1000 @@ -880,7 +995,10 @@ fn propose_charges_correct_fee_with_signer_factor() { let expected_fee = 1030; let deposit = 100; // ProposalDepositParam - assert_eq!(Balances::free_balance(proposer), initial_balance - deposit - expected_fee); + assert_eq!( + Balances::free_balance(proposer.clone()), + initial_balance - deposit - expected_fee + ); // Fee is burned (reduces total issuance) }); } @@ -893,22 +1011,29 @@ fn dissolve_multisig_works() { let signers = vec![bob(), charlie()]; let deposit = 500; let fee = 1000; - let initial_balance = Balances::free_balance(creator); + let initial_balance = Balances::free_balance(creator.clone()); // Create - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); - assert_eq!(Balances::reserved_balance(creator), deposit); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); + assert_eq!(Balances::reserved_balance(creator.clone()), deposit); let multisig_address = Multisig::derive_multisig_address(&signers, 0); // Try to dissolve immediately (success) - assert_ok!(Multisig::dissolve_multisig(RuntimeOrigin::signed(creator), multisig_address)); + assert_ok!(Multisig::dissolve_multisig( + RuntimeOrigin::signed(creator.clone()), + multisig_address.clone() + )); // Check cleanup - assert!(!Multisigs::::contains_key(multisig_address)); - assert_eq!(Balances::reserved_balance(creator), 0); + 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), initial_balance - fee); + assert_eq!(Balances::free_balance(creator.clone()), initial_balance - fee); }); } @@ -918,16 +1043,28 @@ fn dissolve_multisig_fails_with_proposals() { System::set_block_number(1); let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); // Create proposal let call = make_call(vec![1]); - assert_ok!(Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address, call, 100)); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call, + 100 + )); // Try to dissolve assert_noop!( - Multisig::dissolve_multisig(RuntimeOrigin::signed(creator), multisig_address), + Multisig::dissolve_multisig( + RuntimeOrigin::signed(creator.clone()), + multisig_address.clone() + ), Error::::ProposalsExist ); }); @@ -939,7 +1076,11 @@ fn per_signer_proposal_limit_enforced() { System::set_block_number(1); let creator = alice(); let signers = vec![bob(), charlie()]; - assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(creator), signers.clone(), 2)); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + 2 + )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); // MaxActiveProposals = 10, MaxTotalProposalsInStorage = 20 @@ -950,7 +1091,7 @@ fn per_signer_proposal_limit_enforced() { for i in 0..10 { assert_ok!(Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), make_call(vec![i]), 1000 )); @@ -960,7 +1101,7 @@ fn per_signer_proposal_limit_enforced() { assert_noop!( Multisig::propose( RuntimeOrigin::signed(bob()), - multisig_address, + multisig_address.clone(), make_call(vec![99]), 2000 ), @@ -970,7 +1111,7 @@ fn per_signer_proposal_limit_enforced() { // But Charlie can still create (independent limit) assert_ok!(Multisig::propose( RuntimeOrigin::signed(charlie()), - multisig_address, + multisig_address.clone(), make_call(vec![100]), 2000 )); From a695ab764132f6d75970fb89862dfacf110930ef Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 27 Jan 2026 14:08:18 +0800 Subject: [PATCH 27/27] fix: Review cleaning --- pallets/multisig/README.md | 16 ++-- pallets/multisig/src/lib.rs | 28 +++---- pallets/multisig/src/mock.rs | 4 +- pallets/multisig/src/tests.rs | 134 +++++++++++++++++++++++++++++++- pallets/multisig/src/weights.rs | 102 ++++++++++++------------ runtime/src/configs/mod.rs | 2 - 6 files changed, 201 insertions(+), 85 deletions(-) diff --git a/pallets/multisig/README.md b/pallets/multisig/README.md index 95fdae25..8d259e3a 100644 --- a/pallets/multisig/README.md +++ b/pallets/multisig/README.md @@ -62,7 +62,6 @@ Creates a new proposal for multisig execution. **Validation:** - Caller must be a signer - Call size must be ≤ MaxCallSize -- Multisig cannot have MaxActiveProposals or more open proposals - Multisig cannot have MaxTotalProposalsInStorage or more total proposals in storage - Caller cannot exceed their per-signer proposal limit (`MaxTotalProposalsInStorage / signers_count`) - Expiry must be in the future (expiry > current_block) @@ -240,14 +239,10 @@ matches!(call, - **MaxSigners**: Maximum signers per multisig - Trade-off: Higher → more flexible governance, more computation per approval -- **MaxActiveProposals**: Maximum concurrent active proposals per multisig - - Trade-off: Lower → better spam protection, may limit legitimate use - - Prevents flooding attacks - - **MaxTotalProposalsInStorage**: Maximum total proposals (Active + Executed + Cancelled) - Trade-off: Higher → more flexible, more storage risk - Forces periodic cleanup to continue operating - - Recommend: 2× MaxActiveProposals + - **Auto-cleanup**: Expired proposals are automatically removed when new proposals are created - **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 @@ -276,7 +271,7 @@ MultisigData { 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 (for MaxActiveProposals check) + active_proposals: u32, // Count of open proposals (monitoring/analytics) proposals_per_signer: BoundedBTreeMap, // Per-signer proposal count (filibuster protection) } ``` @@ -330,7 +325,6 @@ Internal counter for generating unique multisig addresses. Not exposed via API. - `CallTooLarge` - Encoded call exceeds MaxCallSize - `InvalidCall` - Call decoding failed during execution - `InsufficientBalance` - Not enough funds for fee/deposit -- `TooManyActiveProposals` - Multisig has MaxActiveProposals open proposals - `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) @@ -421,8 +415,9 @@ This event structure is optimized for indexing by SubSquid and similar indexers: ### Spam Prevention - Fees (non-refundable, burned) prevent proposal spam - Deposits (refundable) prevent storage bloat -- MaxActiveProposals limits per-multisig open proposals +- 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 ### Storage Cleanup - Grace period allows proposers priority cleanup @@ -461,8 +456,7 @@ impl pallet_multisig::Config for Runtime { // Storage limits (prevent unbounded growth) type MaxSigners = ConstU32<100>; // Max complexity - type MaxActiveProposals = ConstU32<100>; // Spam protection - type MaxTotalProposalsInStorage = ConstU32<200>; // Total cap (recommend: 2× active) + type MaxTotalProposalsInStorage = ConstU32<200>; // Total storage cap (auto-cleanup on propose) type MaxCallSize = ConstU32<10240>; // Per-proposal storage limit type MaxExpiryDuration = ConstU32<100_800>; // Max proposal lifetime (~2 weeks @ 12s) diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index d60b6a01..2ac3fda1 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -157,10 +157,6 @@ pub mod pallet { #[pallet::constant] type MaxSigners: Get; - /// Maximum number of active (open) proposals per multisig at any given time - #[pallet::constant] - type MaxActiveProposals: Get; - /// Maximum total number of proposals in storage per multisig (Active + Executed + /// Cancelled) This prevents unbounded storage growth and incentivizes cleanup #[pallet::constant] @@ -363,8 +359,6 @@ pub mod pallet { CallTooLarge, /// Failed to decode call data InvalidCall, - /// Too many active proposals for this multisig - TooManyActiveProposals, /// Too many total proposals in storage for this multisig (cleanup required) TooManyProposalsInStorage, /// This signer has too many proposals in storage (filibuster protection) @@ -552,12 +546,6 @@ pub mod pallet { // Get signers count (used for multiple checks below) let signers_count = multisig_data.signers.len() as u32; - // Check active proposals limit - ensure!( - multisig_data.active_proposals < T::MaxActiveProposals::get(), - Error::::TooManyActiveProposals - ); - // Check total proposals in storage limit (Active + Executed + Cancelled) // This incentivizes cleanup and prevents unbounded storage growth let total_proposals_in_storage = @@ -667,7 +655,21 @@ pub mod pallet { }); // Emit event - Self::deposit_event(Event::ProposalCreated { multisig_address, proposer, proposal_id }); + Self::deposit_event(Event::ProposalCreated { + multisig_address: multisig_address.clone(), + proposer, + proposal_id, + }); + + // 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)?; + } Ok(()) } diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index b9ec85d8..38f241d6 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -90,8 +90,7 @@ impl pallet_balances::Config for Test { parameter_types! { pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); pub const MaxSignersParam: u32 = 10; - pub const MaxActiveProposalsParam: u32 = 50; // For testing - pub const MaxTotalProposalsInStorageParam: u32 = 20; // 2x MaxActiveProposals + 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 @@ -105,7 +104,6 @@ impl pallet_multisig::Config for Test { type RuntimeCall = RuntimeCall; type Currency = Balances; type MaxSigners = MaxSignersParam; - type MaxActiveProposals = MaxActiveProposalsParam; type MaxTotalProposalsInStorage = MaxTotalProposalsInStorageParam; type MaxCallSize = MaxCallSizeParam; type MultisigFee = MultisigFeeParam; diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs index d1760d26..d0d4aa5b 100644 --- a/pallets/multisig/src/tests.rs +++ b/pallets/multisig/src/tests.rs @@ -1,8 +1,8 @@ //! Unit tests for pallet-multisig -use crate::{mock::*, Error, Event, GlobalNonce, Multisigs}; +use crate::{mock::*, Error, Event, GlobalNonce, Multisigs, ProposalStatus, Proposals}; use codec::Encode; -use frame_support::{assert_noop, assert_ok}; +use frame_support::{assert_noop, assert_ok, traits::fungible::Mutate}; use sp_core::crypto::AccountId32; /// Helper function to get Alice's account ID @@ -1083,7 +1083,7 @@ fn per_signer_proposal_limit_enforced() { )); let multisig_address = Multisig::derive_multisig_address(&signers, 0); - // MaxActiveProposals = 10, MaxTotalProposalsInStorage = 20 + // MaxTotalProposalsInStorage = 20 // With 2 signers, each can have max 20/2 = 10 proposals // Only Active proposals count (Executed/Cancelled auto-removed) @@ -1117,3 +1117,131 @@ fn per_signer_proposal_limit_enforced() { )); }); } + +#[test] +fn propose_with_threshold_one_executes_immediately() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![alice(), bob(), charlie()]; + let threshold = 1; // Only 1 approval needed + + // Create multisig with threshold=1 + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + threshold + )); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Fund multisig account for balance transfer + as Mutate<_>>::mint_into(&multisig_address, 50000).unwrap(); + + let initial_dave_balance = Balances::free_balance(&dave()); + + // Alice proposes a transfer - should execute immediately since threshold=1 + let transfer_call = RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { + dest: dave(), + value: 1000, + }); + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(alice()), + multisig_address.clone(), + transfer_call.encode(), + 100 + )); + + let proposal_id = 0; // First proposal + + // Verify the proposal was executed immediately (should NOT exist anymore) + assert!(Proposals::::get(&multisig_address, proposal_id).is_none()); + + // Verify the transfer actually happened + assert_eq!(Balances::free_balance(&dave()), initial_dave_balance + 1000); + + // Verify ProposalExecuted event was emitted + System::assert_has_event( + Event::ProposalExecuted { + multisig_address: multisig_address.clone(), + proposal_id, + proposer: alice(), + call: transfer_call.encode(), + approvers: vec![alice()], + result: Ok(()), + } + .into(), + ); + + // 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); + }); +} + +#[test] +fn propose_with_threshold_two_waits_for_approval() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + + let creator = alice(); + let signers = vec![alice(), bob(), charlie()]; + let threshold = 2; // Need 2 approvals + + // Create multisig with threshold=2 + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + threshold + )); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Fund multisig account + as Mutate<_>>::mint_into(&multisig_address, 50000).unwrap(); + + let initial_dave_balance = Balances::free_balance(&dave()); + + // Alice proposes a transfer - should NOT execute yet + let transfer_call = RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { + dest: dave(), + value: 1000, + }); + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(alice()), + multisig_address.clone(), + transfer_call.encode(), + 100 + )); + + let proposal_id = 0; + + // Verify the proposal still exists (waiting for more approvals) + let proposal = Proposals::::get(&multisig_address, proposal_id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Active); + assert_eq!(proposal.approvals.len(), 1); // Only Alice so far + + // 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) + 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()); + + // Verify the transfer happened + assert_eq!(Balances::free_balance(&dave()), initial_dave_balance + 1000); + }); +} diff --git a/pallets/multisig/src/weights.rs b/pallets/multisig/src/weights.rs index 52471931..140a6e07 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-24, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-01-27, 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` @@ -33,8 +33,8 @@ // --extrinsic=* // --steps=50 // --repeat=20 -// --output=pallets/multisig/src/weights.rs -// --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)] @@ -68,8 +68,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(195_000_000, 10389) + // Minimum execution time: 192_000_000 picoseconds. + Weight::from_parts(197_000_000, 10389) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -79,16 +79,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: `458 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 41_000_000 picoseconds. - Weight::from_parts(41_000_000, 17022) - // Standard Error: 214 - .saturating_add(Weight::from_parts(3_021, 0).saturating_mul(c.into())) - // Standard Error: 10_908 - .saturating_add(Weight::from_parts(13_941_381, 0).saturating_mul(e.into())) + // Minimum execution time: 40_000_000 picoseconds. + Weight::from_parts(162_343_032, 17022) + // Standard Error: 41_034 + .saturating_add(Weight::from_parts(14_232_109, 0).saturating_mul(e.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) @@ -105,9 +103,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `766 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(15_161_664, 17022) - // Standard Error: 27 - .saturating_add(Weight::from_parts(396, 0).saturating_mul(c.into())) + Weight::from_parts(16_119_988, 17022) + // Standard Error: 42 + .saturating_add(Weight::from_parts(766, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -121,9 +119,9 @@ impl WeightInfo for SubstrateWeight { // Measured: `734 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(27_171_358, 17022) - // Standard Error: 35 - .saturating_add(Weight::from_parts(528, 0).saturating_mul(c.into())) + Weight::from_parts(33_804_547, 17022) + // Standard Error: 105 + .saturating_add(Weight::from_parts(802, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -136,10 +134,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `734 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(18_990_756, 17022) - // Standard Error: 11 - .saturating_add(Weight::from_parts(253, 0).saturating_mul(c.into())) + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(24_824_223, 17022) + // Standard Error: 65 + .saturating_add(Weight::from_parts(117, 0).saturating_mul(c.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -152,7 +150,7 @@ impl WeightInfo for SubstrateWeight { // Measured: `764` // Estimated: `17022` // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + Weight::from_parts(24_000_000, 17022) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -165,10 +163,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(7_138_812, 17022) - // Standard Error: 21_568 - .saturating_add(Weight::from_parts(13_690_513, 0).saturating_mul(p.into())) + // Minimum execution time: 24_000_000 picoseconds. + Weight::from_parts(27_900_496, 17022) + // Standard Error: 31_493 + .saturating_add(Weight::from_parts(13_930_528, 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)) @@ -186,7 +184,7 @@ impl WeightInfo for SubstrateWeight { // Measured: `538` // Estimated: `17022` // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + Weight::from_parts(20_000_000, 17022) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -202,8 +200,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `152` // Estimated: `10389` - // Minimum execution time: 190_000_000 picoseconds. - Weight::from_parts(195_000_000, 10389) + // Minimum execution time: 192_000_000 picoseconds. + Weight::from_parts(197_000_000, 10389) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -213,16 +211,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: `458 + e * (215 ±0)` // Estimated: `17022 + e * (16032 ±0)` - // Minimum execution time: 41_000_000 picoseconds. - Weight::from_parts(41_000_000, 17022) - // Standard Error: 214 - .saturating_add(Weight::from_parts(3_021, 0).saturating_mul(c.into())) - // Standard Error: 10_908 - .saturating_add(Weight::from_parts(13_941_381, 0).saturating_mul(e.into())) + // Minimum execution time: 40_000_000 picoseconds. + Weight::from_parts(162_343_032, 17022) + // Standard Error: 41_034 + .saturating_add(Weight::from_parts(14_232_109, 0).saturating_mul(e.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(e.into()))) .saturating_add(RocksDbWeight::get().writes(2_u64)) @@ -239,9 +235,9 @@ impl WeightInfo for () { // Measured: `766 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 14_000_000 picoseconds. - Weight::from_parts(15_161_664, 17022) - // Standard Error: 27 - .saturating_add(Weight::from_parts(396, 0).saturating_mul(c.into())) + Weight::from_parts(16_119_988, 17022) + // Standard Error: 42 + .saturating_add(Weight::from_parts(766, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -255,9 +251,9 @@ impl WeightInfo for () { // Measured: `734 + c * (1 ±0)` // Estimated: `17022` // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(27_171_358, 17022) - // Standard Error: 35 - .saturating_add(Weight::from_parts(528, 0).saturating_mul(c.into())) + Weight::from_parts(33_804_547, 17022) + // Standard Error: 105 + .saturating_add(Weight::from_parts(802, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -270,10 +266,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `734 + c * (1 ±0)` // Estimated: `17022` - // Minimum execution time: 18_000_000 picoseconds. - Weight::from_parts(18_990_756, 17022) - // Standard Error: 11 - .saturating_add(Weight::from_parts(253, 0).saturating_mul(c.into())) + // Minimum execution time: 19_000_000 picoseconds. + Weight::from_parts(24_824_223, 17022) + // Standard Error: 65 + .saturating_add(Weight::from_parts(117, 0).saturating_mul(c.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -286,7 +282,7 @@ impl WeightInfo for () { // Measured: `764` // Estimated: `17022` // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(21_000_000, 17022) + Weight::from_parts(24_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -299,10 +295,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(7_138_812, 17022) - // Standard Error: 21_568 - .saturating_add(Weight::from_parts(13_690_513, 0).saturating_mul(p.into())) + // Minimum execution time: 24_000_000 picoseconds. + Weight::from_parts(27_900_496, 17022) + // Standard Error: 31_493 + .saturating_add(Weight::from_parts(13_930_528, 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)) @@ -320,7 +316,7 @@ impl WeightInfo for () { // Measured: `538` // Estimated: `17022` // Minimum execution time: 20_000_000 picoseconds. - Weight::from_parts(22_000_000, 17022) + Weight::from_parts(20_000_000, 17022) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 4c875c5b..9746f148 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -566,7 +566,6 @@ impl pallet_assets_holder::Config for Runtime { parameter_types! { pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); pub const MaxSigners: u32 = 100; - pub const MaxActiveProposals: u32 = 100; // Max active proposals per multisig pub const MaxTotalProposalsInStorage: u32 = 200; // Max total in storage (Active + Executed + Cancelled) pub const MaxCallSize: u32 = 10240; // 10KB pub const MultisigFee: Balance = 100 * MILLI_UNIT; // 0.1 UNIT (non-refundable) @@ -582,7 +581,6 @@ impl pallet_multisig::Config for Runtime { type RuntimeCall = RuntimeCall; type Currency = Balances; type MaxSigners = MaxSigners; - type MaxActiveProposals = MaxActiveProposals; type MaxTotalProposalsInStorage = MaxTotalProposalsInStorage; type MaxCallSize = MaxCallSize; type MultisigFee = MultisigFee;