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..0d768485 --- /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 = "MIT-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-core.workspace = true +sp-io.workspace = true +sp-runtime.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"] +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", + "frame-support/std", + "frame-system/std", + "log/std", + "pallet-balances/std", + "pallet-timestamp/std", + "scale-info/std", + "sp-arithmetic/std", + "sp-core/std", + "sp-io/std", + "sp-runtime/std", +] +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..8d259e3a --- /dev/null +++ b/pallets/multisig/README.md @@ -0,0 +1,483 @@ +# 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. + +## 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_id); +// ✅ 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 +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**: Non-refundable fee (spam prevention) → burned +- **MultisigDeposit**: Refundable deposit (storage rent) → returned when multisig dissolved + +### 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 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) + +**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 + +**Important:** Fee is ALWAYS paid, even if proposal expires or is cancelled. Only deposit is refundable. + +### 3. Approve Transaction +Adds caller's approval to an existing proposal. **If this approval brings the total approvals +to or above the threshold, the transaction will be automatically executed and immediately removed from storage.** + +**Required Parameters:** +- `multisig_address: AccountId` - Target multisig (REQUIRED) +- `proposal_id: u32` - ID (nonce) of the 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 + +**Auto-Execution:** +When approval count reaches the threshold: +- Encoded call is executed as multisig_address origin +- Proposal **immediately removed** from storage +- ProposalDeposit **immediately returned** to proposer +- TransactionExecuted event emitted with execution result + +**Economic Costs:** None (deposit immediately returned on execution) + +### 4. Cancel Transaction +Cancels a proposal and immediately removes it from storage (proposer only). + +**Required Parameters:** +- `multisig_address: AccountId` - Target multisig (REQUIRED) +- `proposal_id: u32` - ID (nonce) of the proposal to cancel (REQUIRED) + +**Validation:** +- Caller must be the proposer +- Proposal must exist and be Active + +**Economic Effects:** +- Proposal **immediately removed** from storage +- ProposalDeposit **immediately returned** to proposer +- Counters decremented + +**Economic Costs:** None (deposit immediately returned) + +**Note:** ProposalFee is NOT refunded - it was burned at proposal creation. + +### 5. Remove Expired +Manually removes expired proposals from storage. Only signers can call this. + +**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) +- `proposal_id: u32` - ID (nonce) of the expired proposal (REQUIRED) + +**Validation:** +- Caller must be a signer of the multisig +- 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) + +**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 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 +- 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 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. + +## 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) +**Purpose:** Spam prevention and deflationary pressure + +- **MultisigFee**: + - Charged on multisig creation + - 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)` + - Burned immediately (reduces total supply) + - **Never returned** (even if proposal expires or is cancelled) + - Makes spam expensive, scales cost with multisig complexity + +**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 + +- **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 + - **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 + +- **MaxSigners**: Maximum signers per multisig + - Trade-off: Higher → more flexible governance, more computation per approval + +- **MaxTotalProposalsInStorage**: Maximum total proposals (Active + Executed + Cancelled) + - Trade-off: Higher → more flexible, more storage risk + - Forces periodic cleanup to continue operating + - **Auto-cleanup**: Expired proposals are automatically removed when new proposals are created + - **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 + - 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 + +### 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 (monitoring/analytics) + proposals_per_signer: BoundedBTreeMap, // Per-signer proposal count (filibuster protection) +} +``` + +### Proposals: DoubleMap +Stores proposal data indexed by (multisig_address, proposal_id): +```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) + status: ProposalStatus, // Active only (Executed/Cancelled are removed immediately) +} +``` + +**Important:** Only **Active** proposals are stored. Executed and Cancelled proposals are **immediately removed** from storage and their deposits are returned. Historical data is available through events (see Historical Data section below). + +### GlobalNonce: u64 +Internal counter for generating unique multisig addresses. Not exposed via API. + +## Events + +- `MultisigCreated { creator, multisig_address, signers, threshold, nonce }` +- `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 + +- `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 (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 +- `InsufficientBalance` - Not enough funds for fee/deposit +- `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) + +## 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 +- 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) +``` + +## Historical Data and Event Indexing + +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**. + +### ProposalExecuted Event + +When a proposal is successfully executed, the pallet emits a comprehensive `ProposalExecuted` event containing all relevant data: + +```rust +Event::ProposalExecuted { + multisig_address: T::AccountId, // The multisig that executed + 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 + result: DispatchResult, // Whether execution succeeded or failed +} +``` + +### Indexing with SubSquid + +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 + +**All events** for complete history: +- `MultisigCreated` - When a multisig is created +- `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 + +- ✅ **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 + +### Spam Prevention +- Fees (non-refundable, burned) prevent proposal spam +- Deposits (refundable) prevent storage bloat +- MaxTotalProposalsInStorage caps total storage per multisig +- Per-signer limits prevent single signer from monopolizing storage (filibuster protection) +- Auto-cleanup of expired proposals reduces storage pressure + +### Storage Cleanup +- Grace period allows proposers priority cleanup +- After grace: public cleanup incentivized +- Batch cleanup via claim_deposits for efficiency + +### Economic Attacks +- **Multisig Spam:** Costs MultisigFee (burned, reduces supply) + - No refund even if never used + - Economic barrier to creation spam +- **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) +- **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 +- 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; + + // Storage limits (prevent unbounded growth) + type MaxSigners = ConstU32<100>; // Max complexity + 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) + + // 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 + +MIT-0 diff --git a/pallets/multisig/src/benchmarking.rs b/pallets/multisig/src/benchmarking.rs new file mode 100644 index 00000000..40c28a2e --- /dev/null +++ b/pallets/multisig/src/benchmarking.rs @@ -0,0 +1,521 @@ +//! 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, ReservableCurrency}; +use frame_system::RawOrigin; + +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( + 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(100000u128)); + + let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); + let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + fund_account::(&signer1, BalanceOf2::::from(100000u128)); + fund_account::(&signer2, BalanceOf2::::from(100000u128)); + + let mut signers = vec![caller.clone(), signer1.clone(), signer2.clone()]; + let threshold = 2u32; + signers.sort(); + + // Create multisig directly in storage + let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); + let multisig_data = MultisigDataOf:: { + signers: bounded_signers, + threshold, + nonce: 0, + proposal_nonce: e, // We'll insert e expired proposals + creator: caller.clone(), + deposit: T::MultisigDeposit::get(), + last_activity: frame_system::Pallet::::block_number(), + active_proposals: e, + proposals_per_signer: BoundedBTreeMap::new(), + }; + Multisigs::::insert(&multisig_address, multisig_data); + + // Insert e expired proposals (worst case for auto-cleanup) + let expired_block = 10u32.into(); + for i in 0..e { + let system_call = frame_system::Call::::remark { remark: vec![i as u8; 10] }; + let call = ::RuntimeCall::from(system_call); + let encoded_call = call.encode(); + let bounded_call: BoundedCallOf = encoded_call.try_into().unwrap(); + let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); + + let proposal_data = ProposalDataOf:: { + proposer: caller.clone(), + call: bounded_call, + expiry: expired_block, + approvals: bounded_approvals, + deposit: 10u32.into(), + status: ProposalStatus::Active, + }; + Proposals::::insert(&multisig_address, i, proposal_data); + } + + // Move past expiry so proposals are expired + frame_system::Pallet::::set_block_number(100u32.into()); + + // Create a 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(); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), encoded_call, expiry); + + // Verify new proposal was created and expired ones were cleaned + let multisig = Multisigs::::get(&multisig_address).unwrap(); + assert_eq!(multisig.active_proposals, 1); // Only new proposal remains + + Ok(()) + } + + #[benchmark] + 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(); + 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(), signer3.clone()]; + let threshold = 3u32; // Need 3 approvals + + // Sort signers to match create_multisig behavior + signers.sort(); + + // Directly insert multisig into storage + let multisig_address = Multisig::::derive_multisig_address(&signers, 0); + let bounded_signers: BoundedSignersOf = signers.clone().try_into().unwrap(); + let multisig_data = MultisigDataOf:: { + signers: bounded_signers, + threshold, + nonce: 0, + proposal_nonce: 1, // We'll insert proposal with id 0 + creator: caller.clone(), + deposit: T::MultisigDeposit::get(), + last_activity: frame_system::Pallet::::block_number(), + active_proposals: 1, + proposals_per_signer: BoundedBTreeMap::new(), + }; + Multisigs::::insert(&multisig_address, multisig_data); + + // Directly insert proposal into storage with 1 approval + // Create a remark call where the remark itself is c bytes + let system_call = frame_system::Call::::remark { remark: vec![1u8; c as usize] }; + let call = ::RuntimeCall::from(system_call); + let encoded_call = call.encode(); + let 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(), + status: ProposalStatus::Active, + }; + + let proposal_id = 0u32; + Proposals::::insert(&multisig_address, proposal_id, proposal_data); + + #[extrinsic_call] + _(RawOrigin::Signed(signer1.clone()), multisig_address.clone(), proposal_id); + + // Verify approval was added (now 2/3, not executed yet) + let proposal = Proposals::::get(&multisig_address, proposal_id).unwrap(); + assert!(proposal.approvals.contains(&signer1)); + assert_eq!(proposal.approvals.len(), 2); + + Ok(()) + } + + #[benchmark] + fn approve_and_execute( + c: Linear<0, { T::MaxCallSize::get().saturating_sub(100) }>, + ) -> Result<(), BenchmarkError> { + // Benchmarks approve() when it triggers auto-execution (threshold reached) + let caller: T::AccountId = whitelisted_caller(); + fund_account::(&caller, BalanceOf2::::from(10000u128)); + + let signer1: T::AccountId = benchmark_account("signer1", 0, SEED); + let signer2: T::AccountId = benchmark_account("signer2", 1, SEED); + 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, + 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); + + // Directly insert proposal with 1 approval (caller already approved) + // signer2 will approve and trigger execution + // Create a remark call where the remark itself is c bytes + let system_call = frame_system::Call::::remark { remark: vec![1u8; c as usize] }; + let call = ::RuntimeCall::from(system_call); + let encoded_call = call.encode(); + let expiry = frame_system::Pallet::::block_number() + 1000u32.into(); + let bounded_call: BoundedCallOf = encoded_call.clone().try_into().unwrap(); + // Only 1 approval so far + let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); + + let proposal_data = ProposalDataOf:: { + proposer: caller.clone(), + call: bounded_call, + expiry, + approvals: bounded_approvals, + deposit: 10u32.into(), + status: ProposalStatus::Active, + }; + + let proposal_id = 0u32; + Proposals::::insert(&multisig_address, proposal_id, proposal_data); + + // signer2 approves, reaching threshold (2/2), triggering auto-execution + #[extrinsic_call] + approve(RawOrigin::Signed(signer2.clone()), multisig_address.clone(), proposal_id); + + // Verify proposal was removed from storage (auto-deleted after execution) + assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); + + Ok(()) + } + + #[benchmark] + 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)); + + 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, + 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); + + // Directly insert proposal into storage + // Create a remark call where the remark itself is c bytes + let system_call = frame_system::Call::::remark { remark: vec![1u8; c as usize] }; + let 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(), + status: ProposalStatus::Active, + }; + + let proposal_id = 0u32; + Proposals::::insert(&multisig_address, proposal_id, proposal_data); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_id); + + // Verify proposal was removed from storage (auto-deleted after cancellation) + assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); + + 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, + 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); + + // 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(), + status: ProposalStatus::Active, + }; + + let proposal_id = 0u32; + Proposals::::insert(&multisig_address, proposal_id, proposal_data); + + // Move past expiry + frame_system::Pallet::::set_block_number(100u32.into()); + + // Call as signer (caller is one of signers) + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone(), proposal_id); + + // Verify proposal was removed + assert!(!Proposals::::contains_key(&multisig_address, proposal_id)); + + Ok(()) + } + + #[benchmark] + 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)); + + 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, + proposal_nonce: p, // We'll insert p proposals with ids 0..p-1 + creator: caller.clone(), + deposit: T::MultisigDeposit::get(), + last_activity: 1u32.into(), + active_proposals: p, + proposals_per_signer: BoundedBTreeMap::new(), + }; + Multisigs::::insert(&multisig_address, multisig_data); + + // Create multiple expired proposals directly in storage + let expiry = 10u32.into(); // Already expired + + for i in 0..p { + let system_call = frame_system::Call::::remark { remark: vec![i as u8; 32] }; + let call = ::RuntimeCall::from(system_call); + let encoded_call = call.encode(); + let bounded_call: BoundedCallOf = encoded_call.clone().try_into().unwrap(); + let bounded_approvals: BoundedApprovalsOf = vec![caller.clone()].try_into().unwrap(); + + let proposal_data = ProposalDataOf:: { + proposer: caller.clone(), + call: bounded_call, + expiry, + approvals: bounded_approvals, + deposit: 10u32.into(), + status: ProposalStatus::Active, + }; + + Proposals::::insert(&multisig_address, i, proposal_data); + } + + // Move past expiry + frame_system::Pallet::::set_block_number(100u32.into()); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), multisig_address.clone()); + + // Verify all expired proposals were cleaned up + assert_eq!(Proposals::::iter_key_prefix(&multisig_address).count(), 0); + + 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(()) + } + + 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..2ac3fda1 --- /dev/null +++ b/pallets/multisig/src/lib.rs @@ -0,0 +1,1110 @@ +//! # 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, 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 +{ + /// List of signers who can approve transactions + pub signers: BoundedSigners, + /// Number of approvals required to execute a transaction + pub threshold: u32, + /// Global unique identifier for this multisig (for address derivation) + pub nonce: u64, + /// Proposal counter for unique proposal hashes + 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, + /// Counter of proposals in storage per signer (for filibuster protection) + pub proposals_per_signer: BoundedProposalsPerSigner, +} + +impl< + BlockNumber: Default, + AccountId: Default, + BoundedSigners: Default, + Balance: Default, + BoundedProposalsPerSigner: Default, + > Default + for MultisigData +{ + fn default() -> Self { + Self { + signers: Default::default(), + threshold: 1, + nonce: 0, + proposal_nonce: 0, + creator: Default::default(), + deposit: Default::default(), + last_activity: Default::default(), + active_proposals: 0, + proposals_per_signer: Default::default(), + } + } +} + +/// Proposal status +#[derive(Encode, Decode, MaxEncodedLen, Clone, TypeInfo, RuntimeDebug, PartialEq, Eq)] +pub enum ProposalStatus { + /// Proposal is active and awaiting approvals + Active, + /// Proposal was executed successfully + Executed, + /// Proposal was cancelled by proposer + Cancelled, +} + +/// 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 only when proposal is removed) + pub deposit: Balance, + /// Current status of the proposal + pub status: ProposalStatus, +} + +/// 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, DispatchResultWithPostInfo, GetDispatchInfo, Pays, PostDispatchInfo, + }, + pallet_prelude::*, + traits::{Currency, ReservableCurrency}, + PalletId, + }; + use frame_system::pallet_prelude::*; + use sp_arithmetic::traits::Saturating; + use sp_runtime::{ + traits::{Dispatchable, Hash, TrailingZeroInput}, + Permill, + }; + + #[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 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; + + /// Fee charged for creating a multisig (non-refundable, burned) + #[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>; + + /// Fee charged for creating a proposal (non-refundable, paid always) + #[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; + + /// 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; + } + + /// 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 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 + 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_nonce) + #[pallet::storage] + #[pallet::getter(fn proposals)] + pub type Proposals = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Twox64Concat, + u32, + 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 proposal has been created + 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_id: u32, + approvals_count: u32, + }, + /// A proposal has been executed + /// Contains all data needed for indexing by SubSquid + ProposalExecuted { + multisig_address: T::AccountId, + proposal_id: u32, + proposer: T::AccountId, + call: Vec, + approvers: Vec, + result: DispatchResult, + }, + /// A proposal has been cancelled by the proposer + ProposalCancelled { + multisig_address: T::AccountId, + proposer: T::AccountId, + proposal_id: u32, + }, + /// Expired proposal was removed from storage + ProposalRemoved { + multisig_address: T::AccountId, + proposal_id: u32, + proposer: T::AccountId, + removed_by: T::AccountId, + }, + /// Batch deposits claimed + DepositsClaimed { + multisig_address: T::AccountId, + claimer: T::AccountId, + total_returned: BalanceOf, + 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] + 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 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 + CallTooLarge, + /// Failed to decode call data + InvalidCall, + /// 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 + ProposalHasDeposit, + /// Proposal has not expired yet + 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] + 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 non-refundable fee (burned). + #[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) + 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 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)?; + + // Get current block for last_activity + let current_block = frame_system::Pallet::::block_number(); + + // Store multisig data + Multisigs::::insert( + &multisig_address, + MultisigDataOf:: { + signers: bounded_signers.clone(), + threshold, + nonce, + proposal_nonce: 0, + creator: creator.clone(), + deposit, + last_activity: current_block, + active_proposals: 0, + proposals_per_signer: Default::default(), + }, + ); + + // 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 (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( + call.len() as u32, + T::MaxTotalProposalsInStorage::get() + ))] + 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); + + // 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<(u32, T::AccountId, BalanceOf)> = + Proposals::::iter_prefix(&multisig_address) + .filter_map(|(id, proposal)| { + if proposal.status == ProposalStatus::Active && + current_block > proposal.expiry + { + Some((id, proposal.proposer, proposal.deposit)) + } else { + None + } + }) + .collect(); + + // Remove expired proposals and return deposits + for (id, expired_proposer, deposit) in expired_proposals.iter() { + Self::remove_proposal_and_return_deposit( + &multisig_address, + *id, + expired_proposer, + *deposit, + ); + + // Emit event for each removed proposal + Self::deposit_event(Event::ProposalRemoved { + multisig_address: multisig_address.clone(), + proposal_id: *id, + proposer: expired_proposer.clone(), + 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; + + // 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 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); + + // Validate expiry is in the future + 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(); + 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) + 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 = current_block; + } + }); + + // Convert to bounded vec + let bounded_call: BoundedCallOf = + call.try_into().map_err(|_| Error::::CallTooLarge)?; + + // 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); + nonce + } else { + 0 // Should never happen due to earlier check + } + }); + + // 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, + status: ProposalStatus::Active, + }; + + // Store proposal with nonce as key (simple and efficient) + Proposals::::insert(&multisig_address, proposal_id, proposal); + + // Increment active proposals counter and per-signer counter + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.active_proposals = multisig.active_proposals.saturating_add(1); + + // Update per-signer counter for filibuster protection + let current_count = + multisig.proposals_per_signer.get(&proposer).copied().unwrap_or(0); + let _ = multisig + .proposals_per_signer + .try_insert(proposer.clone(), current_count.saturating_add(1)); + } + }); + + // Emit event + 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(()) + } + + /// 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_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, + ) -> DispatchResultWithPostInfo { + let approver = ensure_signed(origin)?; + + // Check if approver is a signer + let multisig_data = Self::ensure_is_signer(&multisig_address, &approver)?; + + // Get proposal + let mut proposal = Proposals::::get(&multisig_address, proposal_id) + .ok_or(Error::::ProposalNotFound)?; + + // Calculate actual weight based on real call size (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); + + // 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; + + // Emit approval event + Self::deposit_event(Event::ProposalApproved { + multisig_address: multisig_address.clone(), + approver, + 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_id, proposal)?; + } else { + // Not ready yet, just save the proposal + Proposals::::insert(&multisig_address, proposal_id, proposal); + + // Update multisig last_activity + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.last_activity = frame_system::Pallet::::block_number(); + } + }); + } + + // Return actual weight (refund overpayment) + Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) + } + + /// Cancel a proposed transaction (only by proposer) + /// + /// 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, + ) -> 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); + + // Check if proposal is still active + ensure!(proposal.status == ProposalStatus::Active, Error::::ProposalNotActive); + + // 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 { + multisig_address, + proposer: canceller, + proposal_id, + }); + + // Return actual weight (refund overpayment) + Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes }) + } + + /// Remove expired proposals and return deposits to proposers + /// + /// Can only be called by signers of the multisig. + /// 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 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( + origin: OriginFor, + multisig_address: T::AccountId, + proposal_id: u32, + ) -> DispatchResult { + let caller = ensure_signed(origin)?; + + // Verify caller is a signer + let _multisig_data = Self::ensure_is_signer(&multisig_address, &caller)?; + + // Get proposal + let proposal = Proposals::::get(&multisig_address, proposal_id) + .ok_or(Error::::ProposalNotFound)?; + + // Only Active proposals can be manually removed (Executed/Cancelled already + // auto-removed) + ensure!(proposal.status == ProposalStatus::Active, Error::::ProposalNotActive); + + // Check if expired + let current_block = frame_system::Pallet::::block_number(); + ensure!(current_block > proposal.expiry, Error::::ProposalNotExpired); + + // 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 { + multisig_address, + proposal_id, + proposer: proposal.proposer.clone(), + removed_by: caller, + }); + + Ok(()) + } + + /// Claim all deposits from expired proposals + /// + /// This is a batch operation that removes all expired proposals where: + /// - Caller is the proposer + /// - 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. + #[pallet::call_index(5)] + #[pallet::weight(::WeightInfo::claim_deposits( + T::MaxTotalProposalsInStorage::get() + ))] + 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 mut total_returned = BalanceOf::::zero(); + let mut removed_count = 0u32; + + // Iterate through all proposals for this multisig + // Only Active+Expired proposals exist (Executed/Cancelled are auto-removed) + let proposals_to_remove: Vec<(u32, ProposalDataOf)> = + Proposals::::iter_prefix(&multisig_address) + .filter(|(_, proposal)| { + // Only proposals where caller is proposer + if proposal.proposer != caller { + return false; + } + + // Only Active proposals can exist (Executed/Cancelled auto-removed) + // Must be expired to remove + proposal.status == ProposalStatus::Active && current_block > proposal.expiry + }) + .collect(); + + // Remove proposals and return deposits + for (id, proposal) in proposals_to_remove { + total_returned = total_returned.saturating_add(proposal.deposit); + removed_count = removed_count.saturating_add(1); + + // Remove from storage and return deposit + Self::remove_proposal_and_return_deposit( + &multisig_address, + id, + &proposal.proposer, + proposal.deposit, + ); + + // Emit event for each removed proposal + Self::deposit_event(Event::ProposalRemoved { + multisig_address: multisig_address.clone(), + proposal_id: id, + proposer: caller.clone(), + removed_by: caller.clone(), + }); + } + + // Emit summary event + Self::deposit_event(Event::DepositsClaimed { + multisig_address: multisig_address.clone(), + claimer: caller, + total_returned, + proposals_removed: removed_count, + multisig_removed: false, // Multisig is never auto-removed now + }); + + 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 { + /// 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. + // + // 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 map it deterministically into an AccountId. + let hash = T::Hashing::hash(&data); + 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 + 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 + } + } + + /// 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 + /// + /// Removes the proposal immediately and returns 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_id: u32, + proposal: ProposalDataOf, + ) -> DispatchResult { + // CHECKS: Decode the call (validation) + let call = ::RuntimeCall::decode(&mut &proposal.call[..]) + .map_err(|_| Error::::InvalidCall)?; + + // 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 last_activity BEFORE external interaction + Multisigs::::mutate(&multisig_address, |maybe_multisig| { + if let Some(multisig) = maybe_multisig { + multisig.last_activity = frame_system::Pallet::::block_number(); + } + }); + + // INTERACTIONS: NOW execute the call as the multisig account + // Proposal already removed, so reentrancy cannot affect storage + let result = + 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, + proposal_id, + proposer: proposal.proposer, + call: proposal.call.to_vec(), + approvers: proposal.approvals.to_vec(), + result: result.map(|_| ()).map_err(|e| e.error), + }); + + Ok(()) + } + } +} diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs new file mode 100644 index 00000000..38f241d6 --- /dev/null +++ b/pallets/multisig/src/mock.rs @@ -0,0 +1,143 @@ +//! Mock runtime for testing pallet-multisig + +use crate as pallet_multisig; +use frame_support::{ + parameter_types, + traits::{ConstU32, Everything}, + PalletId, +}; +use sp_core::{crypto::AccountId32, H256}; +use sp_runtime::{ + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, Permill, +}; + +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 = AccountId32; + 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; + pub const MintingAccount: AccountId32 = AccountId32::new([99u8; 32]); +} + +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 = (); + type MintingAccount = MintingAccount; +} + +parameter_types! { + pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); + pub const MaxSignersParam: u32 = 10; + pub const MaxTotalProposalsInStorageParam: u32 = 20; + pub const MaxCallSizeParam: u32 = 1024; + pub const MultisigFeeParam: Balance = 1000; // Non-refundable fee + pub const MultisigDepositParam: Balance = 500; // Refundable deposit + pub const 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 { + type RuntimeCall = RuntimeCall; + type Currency = Balances; + type MaxSigners = MaxSignersParam; + 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 = (); +} + +// 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![ + (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) + .unwrap(); + + t.into() +} diff --git a/pallets/multisig/src/tests.rs b/pallets/multisig/src/tests.rs new file mode 100644 index 00000000..d0d4aa5b --- /dev/null +++ b/pallets/multisig/src/tests.rs @@ -0,0 +1,1247 @@ +//! Unit tests for pallet-multisig + +use crate::{mock::*, Error, Event, GlobalNonce, Multisigs, ProposalStatus, Proposals}; +use codec::Encode; +use frame_support::{assert_noop, assert_ok, traits::fungible::Mutate}; +use sp_core::crypto::AccountId32; + +/// Helper function to get Alice's account ID +fn alice() -> AccountId32 { + account_id(1) +} + +/// Helper function to get Bob's account ID +fn bob() -> AccountId32 { + account_id(2) +} + +/// Helper function to get Charlie's account ID +fn charlie() -> AccountId32 { + account_id(3) +} + +/// Helper function to get Dave's account ID +fn dave() -> AccountId32 { + account_id(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() +} + +/// 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: &AccountId32) -> u32 { + let multisig = Multisigs::::get(multisig_address).expect("Multisig should exist"); + multisig.proposal_nonce.saturating_sub(1) +} + +// ==================== MULTISIG CREATION TESTS ==================== + +#[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.clone()); + let fee = 1000; // MultisigFeeParam + let deposit = 500; // MultisigDepositParam + + // Create multisig + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(creator.clone()), + signers.clone(), + threshold, + )); + + // Check balances + // Deposit is reserved, fee is burned + 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(); + 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.signers.to_vec(), signers); + assert_eq!(multisig_data.active_proposals, 0); + assert_eq!(multisig_data.creator, creator.clone()); + assert_eq!(multisig_data.deposit, deposit); + + // Check that event was emitted + 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.clone()), 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.clone()), 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 number of signers + + assert_noop!( + Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), + Error::::ThresholdTooHigh + ); + }); +} + +#[test] +fn create_multisig_fails_with_duplicate_signers() { + new_test_ext().execute_with(|| { + let creator = alice(); + let signers = vec![bob(), bob(), charlie()]; // Bob twice + let threshold = 2; + + assert_noop!( + Multisig::create_multisig(RuntimeOrigin::signed(creator.clone()), signers, threshold,), + Error::::DuplicateSigner + ); + }); +} + +#[test] +fn create_multiple_multisigs_increments_nonce() { + new_test_ext().execute_with(|| { + let creator = alice(); + let signers1 = vec![bob(), charlie()]; + let signers2 = vec![bob(), dave()]; + + 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); + let multisig2 = Multisig::derive_multisig_address(&signers2, 1); + + assert!(Multisigs::::contains_key(multisig1)); + assert!(Multisigs::::contains_key(multisig2)); + }); +} + +// ==================== PROPOSAL CREATION TESTS ==================== + +#[test] +fn propose_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.clone()), + signers.clone(), + 2 + )); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // Propose a transaction + let proposer = bob(); + let call = make_call(vec![1, 2, 3]); + let expiry = 1000; + + 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.clone()), + multisig_address.clone(), + call.clone(), + expiry + )); + + // Check balances - deposit reserved, fee sent to treasury + assert_eq!(Balances::reserved_balance(proposer.clone()), proposal_deposit); + assert_eq!( + 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); + System::assert_last_event( + Event::ProposalCreated { multisig_address, proposer, proposal_id }.into(), + ); + }); +} + +#[test] +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.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.clone(), call, 1000), + Error::::NotASigner + ); + }); +} + +// ==================== APPROVAL TESTS ==================== + +#[test] +fn approve_works() { + 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.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.clone(), + call.clone(), + 1000 + )); + + let proposal_id = get_last_proposal_id(&multisig_address); + + // Charlie approves (now 2/3) + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + multisig_address.clone(), + proposal_id + )); + + // Check event + System::assert_last_event( + Event::ProposalApproved { + multisig_address: multisig_address.clone(), + approver: charlie(), + proposal_id, + approvals_count: 2, + } + .into(), + ); + + // Proposal should still exist (not executed yet) + assert!(crate::Proposals::::contains_key(&multisig_address, proposal_id)); + }); +} + +#[test] +fn approve_auto_executes_when_threshold_reached() { + 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.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.clone(), + call.clone(), + 1000 + )); + + 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.clone(), + proposal_id + )); + + // Check that proposal was executed and immediately removed from storage + 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 + + // Check event was emitted + System::assert_has_event( + Event::ProposalExecuted { + multisig_address, + proposal_id, + proposer: bob(), + call: call.clone(), + approvers: vec![bob(), charlie()], + result: Ok(()), + } + .into(), + ); + }); +} + +// ==================== CANCELLATION TESTS ==================== + +#[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.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.clone()), + multisig_address.clone(), + call.clone(), + 1000 + )); + + let proposal_id = get_last_proposal_id(&multisig_address); + + // Cancel the proposal - immediately removes and returns deposit + assert_ok!(Multisig::cancel( + 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()); + + // Deposit should be returned immediately + assert_eq!(Balances::reserved_balance(proposer.clone()), 0); + + // Check event + System::assert_last_event( + Event::ProposalCancelled { multisig_address, proposer, proposal_id }.into(), + ); + }); +} + +#[test] +fn cancel_fails_if_already_executed() { + 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.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.clone(), + call.clone(), + 1000 + )); + + 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.clone(), + proposal_id + )); + + // Try to cancel executed proposal (already removed, so ProposalNotFound) + assert_noop!( + Multisig::cancel(RuntimeOrigin::signed(bob()), multisig_address.clone(), proposal_id), + Error::::ProposalNotFound + ); + }); +} + +// ==================== DEPOSIT RECOVERY TESTS ==================== + +#[test] +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()]; + 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]); + let expiry = 100; + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call.clone(), + expiry + )); + + let proposal_id = get_last_proposal_id(&multisig_address); + + // Move past expiry + grace period (100 blocks) + System::set_block_number(expiry + 101); + + // Any signer can remove after grace period (charlie is a signer) + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(charlie()), + multisig_address.clone(), + proposal_id + )); + + // Proposal should be gone + assert!(!crate::Proposals::::contains_key(&multisig_address, proposal_id)); + + // Deposit should be returned to proposer + assert_eq!(Balances::reserved_balance(bob()), 0); + }); +} + +#[test] +fn executed_proposals_auto_removed() { + 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.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.clone(), + call.clone(), + 1000 + )); + + 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.clone(), + proposal_id + )); + + // Proposal should be immediately removed + assert!(crate::Proposals::::get(&multisig_address, proposal_id).is_none()); + + // 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.clone(), + proposal_id + ), + Error::::ProposalNotFound + ); + }); +} + +#[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.clone()), + 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.clone(), + call.clone(), + expiry + )); + + 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.clone(), + proposal_id + ), + Error::::NotASigner + ); + + // But charlie (who is a signer) can do it + assert_ok!(Multisig::remove_expired( + RuntimeOrigin::signed(charlie()), + multisig_address.clone(), + proposal_id + )); + }); +} + +#[test] +fn claim_deposits_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.clone()), + signers.clone(), + 2 + )); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // 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.clone(), + call, + 100 + )); + } + + // All reserved + assert_eq!(Balances::reserved_balance(bob()), 300); // 3 * 100 + + // Move past expiry + grace period + System::set_block_number(201); + + // Bob claims all deposits at once + assert_ok!(Multisig::claim_deposits( + RuntimeOrigin::signed(bob()), + multisig_address.clone() + )); + + // All deposits returned + assert_eq!(Balances::reserved_balance(bob()), 0); + + // Check event + System::assert_has_event( + Event::DepositsClaimed { + multisig_address, + claimer: bob(), + total_returned: 300, + proposals_removed: 3, + multisig_removed: false, + } + .into(), + ); + }); +} + +// ==================== HELPER FUNCTION TESTS ==================== + +#[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 is_signer_works() { + new_test_ext().execute_with(|| { + let signers = vec![bob(), charlie()]; + assert_ok!(Multisig::create_multisig(RuntimeOrigin::signed(alice()), signers.clone(), 2)); + + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + assert!(Multisig::is_signer(&multisig_address, &bob())); + assert!(Multisig::is_signer(&multisig_address, &charlie())); + 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.clone()), + signers.clone(), + 2 + )); + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // MaxTotal = 20, 2 signers = 10 each + // 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( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call.clone(), + 1000 + )); + } + // Bob has 10 active = 10 total (at per-signer limit) + + // Create 10 active proposals from Charlie + for i in 10..20 { + let call = make_call(vec![i as u8]); + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(charlie()), + multisig_address.clone(), + call.clone(), + 1000 + )); + } + // Charlie has 10 active = 10 total (at per-signer limit) + // Total: 20 active (AT LIMIT) + + // Try to add 21st - should fail on total limit + let call = make_call(vec![99]); + assert_noop!( + Multisig::propose(RuntimeOrigin::signed(bob()), multisig_address.clone(), call, 2000), + Error::::TooManyProposalsInStorage + ); + }); +} + +#[test] +fn only_active_proposals_remain_in_storage() { + 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.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) + + // 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( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call.clone(), + 1000 + )); + + if i < 5 { + let id = get_last_proposal_id(&multisig_address); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(charlie()), + 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.clone(), + id + )); + } + } + // Bob now has 4 Active in storage (i=6,7,8,9), 5 executed + 1 cancelled were removed + + // Bob can create 6 more to reach his per-signer limit (10) + for i in 10..16 { + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + make_call(vec![i]), + 2000 + )); + } + // Bob: 10 Active (at per-signer limit) + + // Bob cannot create 11th + assert_noop!( + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + make_call(vec![99]), + 3000 + ), + Error::::TooManyProposalsPerSigner + ); + }); +} + +#[test] +fn auto_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.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.clone(), + make_call(vec![i]), + 100 + )); + } + // Bob: 10 Active (at per-signer limit) + + // Bob cannot create more (at limit) + assert_noop!( + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + make_call(vec![99]), + 200 + ), + Error::::TooManyProposalsPerSigner + ); + + // Move past expiry + System::set_block_number(101); + + // Now Bob can create new - propose() auto-cleans expired + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + make_call(vec![99]), + 200 + )); + + // Verify old proposals were removed + let count = crate::Proposals::::iter_prefix(&multisig_address).count(); + assert_eq!(count, 1); // Only the new one remains + }); +} + +#[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.clone()), + 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.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.clone(), + call.clone(), + 100 + ), + Error::::ExpiryInPast + ); + + // Valid: expiry in the future + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + call, + 101 + )); + }); +} + +#[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.clone()), + 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.clone(), + call.clone(), + 10101 + ), + Error::::ExpiryTooFar + ); + + // Try with expiry way beyond the limit + assert_noop!( + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + 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.clone(), + call, + 10101 + )); + }); +} + +#[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.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.clone()); + + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(proposer.clone()), + 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.clone()), + initial_balance - deposit - expected_fee + ); + // Fee is burned (reduces total issuance) + }); +} + +#[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.clone()); + + // Create + 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.clone()), + multisig_address.clone() + )); + + // Check cleanup + assert!(!Multisigs::::contains_key(&multisig_address)); + assert_eq!(Balances::reserved_balance(creator.clone()), 0); + // Balance returned (minus burned fee) + assert_eq!(Balances::free_balance(creator.clone()), initial_balance - fee); + }); +} + +#[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.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.clone(), + call, + 100 + )); + + // Try to dissolve + assert_noop!( + Multisig::dissolve_multisig( + RuntimeOrigin::signed(creator.clone()), + multisig_address.clone() + ), + Error::::ProposalsExist + ); + }); +} + +#[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.clone()), + signers.clone(), + 2 + )); + let multisig_address = Multisig::derive_multisig_address(&signers, 0); + + // MaxTotalProposalsInStorage = 20 + // With 2 signers, each can have max 20/2 = 10 proposals + // Only Active proposals count (Executed/Cancelled auto-removed) + + // Bob creates 10 active proposals (at per-signer limit) + for i in 0..10 { + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + make_call(vec![i]), + 1000 + )); + } + + // Bob at limit - tries to create 11th + assert_noop!( + Multisig::propose( + RuntimeOrigin::signed(bob()), + multisig_address.clone(), + make_call(vec![99]), + 2000 + ), + Error::::TooManyProposalsPerSigner + ); + + // But Charlie can still create (independent limit) + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(charlie()), + multisig_address.clone(), + make_call(vec![100]), + 2000 + )); + }); +} + +#[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 new file mode 100644 index 00000000..140a6e07 --- /dev/null +++ b/pallets/multisig/src/weights.rs @@ -0,0 +1,323 @@ +// 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-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` + +// Executed Command: +// ./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 + +#![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(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(p: u32, ) -> Weight; + fn dissolve_multisig() -> 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(6924), added: 9399, mode: `MaxEncodedLen`) + fn create_multisig() -> Weight { + // Proof Size summary in bytes: + // Measured: `152` + // Estimated: `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)) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:201 w:201) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + /// The range of component `e` is `[0, 200]`. + fn propose(_c: u32, e: u32, ) -> Weight { + // 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(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)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(e.into()))) + .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + fn approve(c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `766 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 14_000_000 picoseconds. + 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)) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + fn approve_and_execute(c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `734 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 25_000_000 picoseconds. + 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)) + } + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + fn cancel(c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `734 + c * (1 ±0)` + // Estimated: `17022` + // 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)) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + fn remove_expired() -> Weight { + // Proof Size summary in bytes: + // Measured: `764` + // Estimated: `17022` + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(24_000_000, 17022) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: `Multisig::Proposals` (r:201 w:200) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// The range of component `p` is `[1, 200]`. + fn claim_deposits(p: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `625 + p * (237 ±0)` + // Estimated: `17022 + p * (16032 ±0)` + // 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)) + .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(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: `538` + // Estimated: `17022` + // Minimum execution time: 20_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)) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `Multisig::GlobalNonce` (r:1 w:1) + /// Proof: `Multisig::GlobalNonce` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + fn create_multisig() -> Weight { + // Proof Size summary in bytes: + // Measured: `152` + // Estimated: `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)) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:201 w:201) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + /// The range of component `e` is `[0, 200]`. + fn propose(_c: u32, e: u32, ) -> Weight { + // 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(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)) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(e.into()))) + .saturating_add(Weight::from_parts(0, 16032).saturating_mul(e.into())) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + fn approve(c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `766 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 14_000_000 picoseconds. + 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)) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + fn approve_and_execute(c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `734 + c * (1 ±0)` + // Estimated: `17022` + // Minimum execution time: 25_000_000 picoseconds. + 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)) + } + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// The range of component `c` is `[0, 10140]`. + fn cancel(c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `734 + c * (1 ±0)` + // Estimated: `17022` + // 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)) + } + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Proposals` (r:1 w:1) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + fn remove_expired() -> Weight { + // Proof Size summary in bytes: + // Measured: `764` + // Estimated: `17022` + // Minimum execution time: 20_000_000 picoseconds. + Weight::from_parts(24_000_000, 17022) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: `Multisig::Proposals` (r:201 w:200) + /// Proof: `Multisig::Proposals` (`max_values`: None, `max_size`: Some(13557), added: 16032, mode: `MaxEncodedLen`) + /// Storage: `Multisig::Multisigs` (r:1 w:1) + /// Proof: `Multisig::Multisigs` (`max_values`: None, `max_size`: Some(6924), added: 9399, mode: `MaxEncodedLen`) + /// The range of component `p` is `[1, 200]`. + fn claim_deposits(p: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `625 + p * (237 ±0)` + // Estimated: `17022 + p * (16032 ±0)` + // 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)) + .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(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: `538` + // Estimated: `17022` + // Minimum execution time: 20_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/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 eac5366e..9746f148 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -562,6 +562,37 @@ 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 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) +} + +/// Whitelist for calls that can be proposed in multisigs +impl pallet_multisig::Config for Runtime { + type RuntimeCall = RuntimeCall; + type Currency = Balances; + type MaxSigners = MaxSigners; + 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; +} + 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; } 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();