A secure, wallet-first stateful multisig implementation for Substrate blockchains, inspired by Gnosis Safe.
Pactum introduces a stateful, on-chain multisignature wallet system that creates persistent, sovereign accounts on the blockchain. Unlike traditional stateless multisigs, Pactum's wallets are first-class citizens with their own account IDs, enabling native asset management and seamless integration with other pallets.
-
Build the Node:
cargo build --release
-
Run a Local Development Node:
./target/release/substrate-node-template --dev --tmp
--dev: Runs in development mode with a fresh state on each start--tmp: Uses a temporary database (cleared on restart)
-
Run the Tests:
cargo test -p pallet-multisig
Pactum implements a wallet-first stateful model where each multisig has its own sovereign account. This design enables:
- Native asset management without complex proxy patterns
- Clean integration with other pallets
- Improved user experience with deterministic account addresses
The current implementation enforces immutable ownership for security and simplicity. This decision:
- Eliminates complex edge cases around dynamic ownership changes
- Prevents potential griefing attacks from malicious owner modifications
- Simplifies the security model and audit surface
A production implementation would need a governance mechanism to manage owners, which could be implemented as a future enhancement.
The destroyMultisig function uses clear_prefix to clean up all related storage items. This choice:
- Ensures complete cleanup of all associated data
- Provides a clean slate for storage reclamation
- May have variable weight depending on the number of proposals
-
Wallet Governance
- Add/remove owners through governance proposals
- Adjustable thresholds with appropriate cooldowns
- Emergency recovery mechanisms
-
Economic Security
- Proposal deposits to prevent spam
- Slashing conditions for malicious behavior
- Transaction fee management
-
Call Filtering
- Allowlist/denylist for call destinations
- Spending limits per time period
- Permissioned call types
-
Multi-Asset Support
- Native integration with pallet-assets
- Cross-asset transaction batching
- Asset-specific permissioning
-
Cross-Chain Execution
- XCM integration for cross-chain proposals
- Multi-chain governance
- Bridge interactions
-
Scheduled Transactions
- Integration with pallet-scheduler
- Recurring payments
- Time-locked transactions
The pallet's behavior is defined by its five core extrinsics. Here is a detailed breakdown of the implementation logic and the thought process behind each one.
Purpose: To initialize a new, persistent multisig wallet and record its configuration on-chain.
Step-by-Step Logic:
-
Validation & Security: The first priority is to validate the inputs before any state is written.
- The function first calls
ensure_signedto verify the transaction has a valid signature and to identify the creator. - Next, it converts the user-provided
ownersVecinto aBoundedVec. This is a critical safety measure to prevent a potential Denial-of-Service (DoS) attack where a user could submit a list with millions of owners, bloating storage and computation. If the number of owners exceeds theMaxOwnersconstant defined in the runtime, this conversion fails and the extrinsic exits with an error. - Finally, it performs a sanity check on the
threshold, ensuring it's a logical value (greater than 0 and less than or equal to the number of owners).
- The function first calls
-
State Changes: Once validated, the function proceeds to create the on-chain records.
- It fetches a new, unique
multisig_idfrom theNextMultisigIdstorage counter. It then immediately increments and saves the counter usingchecked_addto prevent potential integer overflows. - It calls the
multi_account_idhelper function. This is the cornerstone of the stateful design, deterministically generating a unique, sovereignAccountIdfor the new wallet based on its ID. - It creates an instance of the
Multisigstruct and inserts it into theMultisigsstorage map, officially bringing the wallet into existence on-chain.
- It fetches a new, unique
-
Notification: The function concludes by emitting a
MultisigCreatedevent, broadcasting themultisig_idand, crucially, the wallet's newmultisig_accountaddress so that users can begin sending funds to it.
Purpose: To allow an authorized owner to formally propose a transaction for the group to approve.
Step-by-Step Logic:
-
Validation & Security:
- It verifies the caller is a signed user.
- It checks that the specified
multisig_idcorresponds to an existing wallet. - Authorization: It performs the core permission check, ensuring the caller's account is present in the multisig's
ownerslist.
-
State Changes:
- It gets a new
proposal_indexfrom the counter dedicated to this specific multisig. Each wallet maintains its own proposal count. - Design Rationale (Storage Optimization): To avoid storing potentially large
RuntimeCalldata on-chain, the function calculates theblake2_256hash of the call. It then creates and stores aProposalstruct containing only this hash and anexecutedflag. - Design Rationale (User Experience): The submitter is automatically added as the first approval. This is a deliberate UX improvement to save the user from having to send a second, separate
confirm_proposaltransaction for their own proposal.
- It gets a new
-
Notification: It emits a
ProposalSubmittedevent, providing thecall_hashso other owners can verify the proposed action off-chain before confirming.
Purpose: To allow other owners to cast their vote of approval for a pending proposal.
Step-by-Step Logic:
-
Validation & Security: This function has the most extensive set of "fail fast" checks to ensure the integrity of the voting process.
- It first checks for a valid signature, an existing multisig, and the caller's ownership status.
- It then verifies that the specified proposal actually exists and has not already been executed.
- Critical Security Check: It checks if the caller's account is already in the
Approvalslist for this proposal. This is vital to prevent a single owner from voting multiple times and artificially meeting the threshold.
-
State Changes: The logic follows a safe "read-modify-write" pattern.
- It reads the current list of
Approvalsfrom storage. - It pushes the new approver's
AccountIdto this list. - It writes the updated list back to storage.
- It reads the current list of
-
Notification: It emits a
Confirmationevent, which signals to UIs that the proposal's approval count has increased.
Purpose: To dispatch a fully approved transaction from the multisig's sovereign account.
Step-by-Step Logic:
-
Validation & Security:
- It first checks that the proposal exists and has not been executed.
- Critical Security Check (
CallHashMismatch): It requires the user to submit the fullcalldata again. The function then hashes this provided call and ensures it matches thecall_hashstored on-chain when the proposal was created. This prevents any "bait-and-switch" attack where a different action could be executed than the one owners approved. - Core Authorization Check: It verifies that the number of approvals in storage is greater than or equal to the multisig's
threshold.
-
State Changes:
- It dispatches the
callusing the multisig's derived sovereignAccountIdas theSignedorigin. This is the moment the multisig "acts" on the blockchain. - Design Rationale (Self-Destruction Safety): After the dispatch, it only updates the proposal's
executedflag if two conditions are met: the dispatch was successful (result.is_ok()) AND the parent multisig still exists (Multisigs::contains_key(multisig_id)). This second check is a crucial safety feature to handle the specific edge case where the executed call wasdestroy_multisig, preventing the code from trying to write to storage that has just been deleted.
- It dispatches the
-
Notification: It emits a
ProposalExecutedevent, which includes theresultof the inner dispatched call. This tells users not only that the execution was attempted, but whether the inner call succeeded or failed.
Purpose: To provide a secure mechanism for cleaning up a wallet and its associated storage.
Step-by-Step Logic:
-
Validation & Security:
- Design Rationale (Sovereign Security Model): The most important check is
ensure!(who == multisig_account, ...). This enforces a "self-governance" model. The extrinsic can only be successfully called if its origin is the multisig's own sovereign account. This means destruction is not a simple user action; it must be proposed, confirmed, and executed like any other high-stakes proposal. - Design Rationale (Fund Safety): The function includes a critical safety net:
ensure!(balance.is_zero(), ...). It checks that the multisig's on-chain balance is zero. This prevents the accidental and irreversible destruction of a wallet that still holds funds, forcing the owners to explicitly empty it first.
- Design Rationale (Sovereign Security Model): The most important check is
-
State Changes:
- It performs a complete cleanup of all storage items associated with the
multisig_id. It usesremovefor single-key maps and the efficientclear_prefixto wipe all proposals and approvals for that wallet.
- It performs a complete cleanup of all storage items associated with the
-
Notification: It emits a
MultisigDestroyedevent to confirm the successful cleanup.