Multi-Sig Admin
Problem
I need a contract where critical actions (like withdrawals or upgrades) can only execute when at least 2 of 3 designated admins approve.
Solution
contract MultiSigVault {
field admins: set<address> = {deployer}
field threshold: uint = 2
// Pending proposals
field proposal_id: uint = 0
field proposals: map<uint, Proposal>
field approvals: map<uint, set<address>>
struct Proposal {
target: address
value: amount
data: bytes
executed: bool
deadline: time
}
event ProposalCreated(id: uint indexed, proposer: address indexed)
event Approved(id: uint indexed, approver: address indexed)
event Executed(id: uint indexed)
event Revoked(id: uint indexed, revoker: address indexed)
error NotAdmin()
error AlreadyApproved()
error AlreadyExecuted()
error ThresholdNotMet()
error Expired()
guard is_admin {
given admins.contains(caller) or revert_with NotAdmin()
}
action propose(target: address, value: amount, data: bytes)
is_admin
returns (id: uint) {
let id = proposal_id
proposals[id] = Proposal {
target: target,
value: value,
data: data,
executed: false,
deadline: block.timestamp + 7 days
}
approvals[id] = {}
proposal_id = proposal_id + 1
emit ProposalCreated(id, caller)
return id
}
action approve(id: uint) is_admin {
given !proposals[id].executed or revert_with AlreadyExecuted()
given block.timestamp <= proposals[id].deadline or revert_with Expired()
given !approvals[id].contains(caller) or revert_with AlreadyApproved()
approvals[id].add(caller)
emit Approved(id, caller)
}
action execute(id: uint) is_admin {
given !proposals[id].executed or revert_with AlreadyExecuted()
given block.timestamp <= proposals[id].deadline or revert_with Expired()
given approvals[id].size() >= threshold or revert_with ThresholdNotMet()
proposals[id].executed = true
external_call(proposals[id].target, proposals[id].value, proposals[id].data)
emit Executed(id)
}
action revoke(id: uint) is_admin {
given approvals[id].contains(caller) or revert_with NotAdmin()
approvals[id].remove(caller)
emit Revoked(id, caller)
}
}
Explanation
set<address>foradminsandapprovals— O(1) membership checks, no iteration neededproposal_idis a monotonically increasing counter (no reuse of IDs)guard is_adminis a reusable guard block — more readable than repeating the check- The 7-day deadline prevents stale proposals from accumulating
- CEI pattern in
execute: markexecuted = truebefore the external call
Gas Estimate
| Operation | Gas |
|---|---|
propose | ~80,000 |
approve | ~35,000 |
execute (simple transfer) | ~50,000 |
revoke | ~30,000 |
Common Pitfalls
- Missing expiry: Without a deadline, proposals accumulate indefinitely and could be executed long after they’re relevant.
- No revoke: Admins who change their mind need a way to withdraw approval.
- External call without CEI: Mark
executed = truebeforeexternal_callto prevent reentrancy. - Threshold of 1: A threshold of 1 is functionally equivalent to a single owner. Enforce minimum threshold of 2.
- Admin set not updatable: This recipe has immutable admins. For admin rotation, add a
add_admin/remove_adminaction (also gated by multisig).
Variations
Simple M-of-N without data
For simpler cases (just “approve this specific operation”):
field pending_action: address = address(0)
field action_approvals: set<address>
action initiate_withdrawal(to: address) is_admin {
pending_action = to
action_approvals = {}
}
action approve_withdrawal() is_admin {
action_approvals.add(caller)
if action_approvals.size() >= threshold {
transfer(pending_action, balance)
pending_action = address(0)
}
}