Threshold (M-of-N)¶
An M-of-N approval contract: an action executes only after at least M distinct signers have voted for it. The stdlib ships a fuller Multisig contract (fourier/stdlib/multisig.fou) which uses on-chain PQC signature verification; this page walks a simpler "approval via direct call" variant suitable for understanding the pattern.
Source: not shipped as a separate .fou in v1 — use this page as a pattern reference and adapt to your contract. The fuller stdlib version is at fourier/stdlib/multisig.fou.
Sketch¶
contract Threshold {
storage threshold: uint @ 0;
storage signer_count: uint @ 1;
storage is_signer: map[address, uint] @ 2;
storage next_id: uint @ 3;
storage target: map[uint, address] @ 4;
storage value: map[uint, uint] @ 5;
storage executed: map[uint, uint] @ 6;
storage approvals: map[uint, map[address, uint]] @ 7;
storage approval_count: map[uint, uint] @ 8;
event Proposed(id: uint, by: address, target: address);
event Approved(id: uint, by: address);
event Executed(id: uint);
pub fn propose(t: address, v: uint) -> uint {
require(is_signer[caller()] == 1);
let id: uint = next_id;
target[id] = t;
value[id] = v;
executed[id] = 0;
approval_count[id] = 0;
next_id = id + 1;
emit Proposed(id, caller(), t);
return id;
}
pub fn approve(id: uint) -> uint {
require(is_signer[caller()] == 1);
require(executed[id] == 0);
require(approvals[id][caller()] == 0);
approvals[id][caller()] = 1;
approval_count[id] = approval_count[id] + 1;
emit Approved(id, caller());
return 1;
}
pub fn execute(id: uint) -> uint {
require(executed[id] == 0);
require(approval_count[id] >= threshold);
executed[id] = 1;
let cd: bytes = pack_sel(0); // empty calldata — value transfer only
let ok: uint = call_b(target[id], cd, value[id], 200000);
require(ok == 1);
emit Executed(id);
return 1;
}
}
Walkthrough¶
Storage¶
| Slot | Name | Purpose |
|---|---|---|
0 | threshold | M (required approvals) |
1 | signer_count | N (informational; populated separately) |
2 | is_signer | address → 1 if signer |
3 | next_id | Auto-incrementing proposal id |
4 | target | id → target address |
5 | value | id → WAVE to send |
6 | executed | id → 0/1 |
7 | approvals | id → signer → 0/1 |
8 | approval_count | id → count of approvers |
Note: is_signer and threshold are not initialized in this sketch. Add an init() that seeds them, or a separate add_signer / set_threshold admin path gated by some owner.
Selector layout¶
| Selector | Function |
|---|---|
0x01 | propose(address, uint) -> uint |
0x02 | approve(uint) -> uint |
0x03 | execute(uint) -> uint |
Proposal flow¶
- propose: a signer creates a proposal carrying
(target, value). Returns the proposal id. - approve: any signer (including the proposer) votes once. Re-approval reverts because
approvals[id][caller()] == 0would fail. - execute: anyone can trigger execution once
approval_count[id] >= threshold. The contractcall_bs the target with empty calldata and forwardsvalue. The success word is checked; failure reverts everything.
Important subtleties¶
executed[id] = 1is set before the externalcall_b. This is the checks-effects-interactions pattern: if the callee is malicious and tries to re-enterexecute(id), the second invocation hitsrequire(executed[id] == 0)and reverts. No need for a separate reentrancy guard.approvals[id][caller()]uses nested mapping syntax. The slot forapprovals[id][addr]isSHA3(addr_word || SHA3(id_word || slot_7_word)). See Storage / Nested mapping.- The empty-calldata
pack_sel(0)produces a 1-bytebytesvalue containing the byte0x00. The callee's dispatcher will not match selector0x00(which is reserved for the deploy-time empty-calldata short-circuit), so this only "works" if the callee is an EOA or a contract whose code permits empty calldata. For real cross-contract invocation, pass the target function's selector.
Upgrading to PQC¶
The fuller Multisig contract in fourier/stdlib/multisig.fou uses the same pattern but verifies each signer's signature on-chain via verify_sig(...) and a PQC precompile. Use that when you need attestable signatures rather than direct calls from signer EOAs.
The protocol is:
- Signers compute a proposal hash off-chain.
- Each signer ML-DSA-87-signs the hash.
- Signers submit their signatures via
sign(id, sig). - The contract calls
verify_sig(1, pk, hash, sig)and accepts onreturn 1.
This decouples the signing party from the EOA submitting the transaction.
What to try next¶
- Add an
init()that takes no params and seeds threshold/signers from constants. - Make signer set mutable via owner-gated
add_signer/remove_signercalls. - Extend
executeto carry calldata too: storeselector: map[uint, uint]andarg: map[uint, uint], passpack_sel(selector[id], arg[id])tocall_b. - For arbitrary calldata, hash-commit / reveal: store
calldata_hash: map[uint, uint]and require the executor to supply the calldata bytes at execute time (re-hash to verify).