Multisig: M-of-N approval
No single key can act alone. The pattern behind every treasury that has not been drained by one compromised laptop.
A multisig replaces "the owner" with "any M of these N owners". Nothing happens until enough of them independently agree.
The flow is three steps, and each is a separate transaction:
1. propose — an owner puts an action on the record and gets an id back 2. approve — other owners sign on; each approval is counted once 3. execute — once the approval count reaches the threshold, the action runs
The reason it is three steps rather than one is that owners are different people on different machines at different times. The contract is the place they meet.
Count approvers, not approvals. The single most common multisig bug is letting one owner approve the same action repeatedly and drive the counter to the threshold alone. That is a 1-of-N wearing an M-of-N costume.
Record approval per *(action, owner)* pair and ignore a repeat, exactly as you did with allowances. And check the count at execute time, not at approve time — an owner may be removed between the two.
The real contracts/examples/multisig goes considerably further: owner add/remove/replace that must itself go through the multisig (_require_self_call), a nonce to stop replay, and execute_with_permits so owners can approve off-chain with signatures and one person submits the bundle. The core below is the part everything else is built on.
Your turn
Build a 2-of-3 multisig core. propose(action) records an action and returns its id. approve(id) counts the caller once — a second approval from the same owner must NOT raise the count. execute(id) runs only at or above the threshold, and only once.
Hints
if is the whole lesson — without it one owner reaches any threshold alone.One way to do it
from stdlib import abi, events, storage
def _uget(key: bytes) -> int:
raw = storage.get(key, b"")
return 0 if raw == b"" else int.from_bytes(raw, "big")
def _uset(key: bytes, value: int) -> None:
v = int(value)
abi.require(v >= 0, b"negative")
if v == 0:
storage.delete(key)
return
storage.set(key, v.to_bytes(max(1, (v.bit_length() + 7) // 8), "big"))
def _flag(key: bytes) -> bool:
return storage.get(key, b"") == b"\x01"
def _set_flag(key: bytes) -> None:
storage.set(key, b"\x01")
K_THRESHOLD = b"ms:threshold"
K_COUNT = b"ms:count"
def _k_owner(addr: bytes) -> bytes:
return b"ms:owner:" + addr
def _k_action(aid: int) -> bytes:
return b"ms:act:" + int(aid).to_bytes(4, "big")
def _k_approvals(aid: int) -> bytes:
return b"ms:appr:" + int(aid).to_bytes(4, "big")
def _k_approved_by(aid: int, addr: bytes) -> bytes:
return b"ms:by:" + int(aid).to_bytes(4, "big") + b":" + addr
def _k_done(aid: int) -> bytes:
return b"ms:done:" + int(aid).to_bytes(4, "big")
def init(owners: bytes, threshold: int) -> None:
n = len(owners) // 32
for i in range(n):
_set_flag(_k_owner(bytes(owners[i * 32:(i + 1) * 32])))
_uset(K_THRESHOLD, int(threshold))
def is_owner(addr: bytes) -> bool:
return _flag(_k_owner(bytes(addr)))
def approvals(aid: int) -> int:
return _uget(_k_approvals(aid))
def executed(aid: int) -> bool:
return _flag(_k_done(aid))
def propose(action: bytes) -> int:
abi.require(_flag(_k_owner(abi.caller())), b"not_owner")
body = bytes(action)
abi.require(len(body) > 0, b"empty_action")
aid = _uget(K_COUNT) + 1
_uset(K_COUNT, aid)
storage.set(_k_action(aid), body)
events.emit(b"Proposed", {"id": aid, "by": abi.caller()})
return aid
def approve(aid: int) -> int:
abi.require(_flag(_k_owner(abi.caller())), b"not_owner")
abi.require(storage.get(_k_action(aid), b"") != b"", b"no_such_action")
# Count APPROVERS, not approvals. A repeat from the same owner is a no-op.
seen = _k_approved_by(aid, abi.caller())
if not _flag(seen):
_set_flag(seen)
_uset(_k_approvals(aid), _uget(_k_approvals(aid)) + 1)
return _uget(_k_approvals(aid))
def execute(aid: int) -> bytes:
action = storage.get(_k_action(aid), b"")
abi.require(action != b"", b"no_such_action")
abi.require(not _flag(_k_done(aid)), b"already_executed")
abi.require(_uget(_k_approvals(aid)) >= _uget(K_THRESHOLD), b"threshold_not_met")
_set_flag(_k_done(aid))
events.emit(b"Executed", {"id": aid, "action": action})
return action
Claim your 10 ANM
Finish this lesson and claim 10 ANM, once per address. Paid from the Animica treasury in batches — allow a few minutes.
Stuck? Ask
Answered by Animica's own free inference network. It is donated GPU capacity, so give it 20-30 seconds.