A lightweight, generic multisignature voting library for Internet Computer canisters.
This library provides a flexible multisig voting mechanism where multiple owners can propose and vote on arbitrary actions. Once a proposal reaches the required threshold of approvals, it automatically executes.
- Generic payload support: Vote on any Candid-serializable data
- Configurable thresholds: Set minimum approvals required
- Auto-execution: Proposals execute when threshold is met
- Re-entrancy protection: Prevents double execution
- Principal-based ownership: Uses IC Principal for identity
- Byte serialization: Built-in support for persistent storage
Add to your Cargo.toml:
[dependencies]
ic-multisig-voting = { path = "path/to/ic-multisig-voting" }
candid = "0.10"
ic-cdk = "0.16"use ic_multisig_voting::{Multisig, Proposal};
use candid::{CandidType, Principal};
use serde::{Deserialize, Serialize};
#[derive(CandidType, Deserialize, Serialize, Clone)]
pub enum MyPayload {
UpdateConfig(String),
TransferFunds { to: Principal, amount: u64 },
}
// Create a multisig with 3 owners requiring 2 approvals
let owners = vec![
Principal::from_text("owner1").unwrap(),
Principal::from_text("owner2").unwrap(),
Principal::from_text("owner3").unwrap(),
];
let mut multisig = Multisig::<MyPayload>::new(owners, 2);
// Owner proposes an action
let proposal_id = multisig.propose(
caller_principal,
MyPayload::UpdateConfig("new_value".to_string())
)?;
// Other owners vote
if let Some(action) = multisig.approve(voter_principal, proposal_id)? {
// Threshold reached! Execute the action
match action {
MyPayload::UpdateConfig(value) => {
// Apply configuration change
},
MyPayload::TransferFunds { to, amount } => {
// Execute transfer
},
}
}use std::cell::RefCell;
thread_local! {
static MULTISIG: RefCell<Option<Multisig<MyPayload>>> = RefCell::new(None);
}
fn with_multisig<F, R>(f: F) -> Result<R, String>
where F: FnOnce(&mut Multisig<MyPayload>) -> R
{
MULTISIG.with(|ms| {
match ms.borrow_mut().as_mut() {
Some(multisig) => Ok(f(multisig)),
None => Err("Multisig not initialized".to_string()),
}
})
}
#[ic_cdk::init]
fn init(owners: Vec<Principal>, threshold: u8) {
MULTISIG.with(|ms| {
*ms.borrow_mut() = Some(Multisig::new(owners, threshold));
});
}
#[ic_cdk::update]
fn propose_action(payload: MyPayload) -> Result<u64, String> {
let caller = ic_cdk::caller();
with_multisig(|ms| ms.propose(caller, payload))
}
#[ic_cdk::update]
fn approve_proposal(id: u64) -> Result<String, String> {
let caller = ic_cdk::caller();
let maybe_payload = with_multisig(|ms| ms.approve(caller, id))?;
match maybe_payload {
Some(action) => {
execute_action(action);
Ok(format!("Proposal {} executed", id))
}
None => Ok(format!("Proposal {} approved, waiting for more votes", id)),
}
}
#[ic_cdk::query]
fn list_proposals() -> Result<Vec<Proposal<MyPayload>>, String> {
with_multisig(|ms| Ok(ms.list_open().into_iter().cloned().collect()))
}use ic_stable_structures::storable::{Bound, Storable};
impl<T: CandidType + Clone + for<'de> Deserialize<'de>> Storable for Multisig<T> {
fn to_bytes(&self) -> std::borrow::Cow<[u8]> {
match self.to_bytes() {
Ok(bytes) => std::borrow::Cow::Owned(bytes),
Err(_) => std::borrow::Cow::Borrowed(&[]), // Handle appropriately
}
}
fn from_bytes(bytes: std::borrow::Cow<[u8]>) -> Self {
Self::from_bytes(bytes.as_ref()).unwrap() // Handle error appropriately
}
const BOUND: Bound = Bound::Unbounded;
}The main multisig state container.
-
new(owners: Vec<Principal>, threshold: u8) -> Self- Creates new multisig with given owners and approval threshold
- Panics if threshold is 0 or exceeds owner count
-
propose(caller: Principal, payload: T) -> Result<ProposalId, String>- Creates new proposal with given payload
- Caller must be an owner
- Proposer automatically approves their own proposal
- Returns unique proposal ID
-
approve(caller: Principal, id: ProposalId) -> Result<Option<T>, String>- Adds caller's approval to proposal
- Returns
Some(payload)if threshold reached and not previously executed - Returns
Noneif more approvals needed or already executed - Caller must be an owner
list_open() -> Vec<&Proposal<T>>- Returns all unexecuted proposalsget_proposal(id: ProposalId) -> Option<&Proposal<T>>- Get proposal by IDget_owners() -> &BTreeSet<Principal>- Get current ownersget_threshold() -> u8- Get current threshold
add_owner(owner: Principal) -> Result<(), String>- Add new ownerremove_owner(owner: Principal) -> Result<(), String>- Remove owner (if threshold allows)set_threshold(new_threshold: u8) -> Result<(), String>- Change approval threshold
to_bytes(&self) -> Result<Vec<u8>, String>- Serialize for storagefrom_bytes(bytes: &[u8]) -> Result<Self, String>- Deserialize from storage
Individual proposal data structure.
pub struct Proposal<T> {
pub id: ProposalId,
pub payload: T,
pub approvals: BTreeSet<Principal>,
pub executed: bool,
}All methods return Result types with descriptive error messages:
"caller is not an owner"- Non-owner tried to propose/approve"no such proposal"- Invalid proposal ID"already an owner"- Attempted to add existing owner"removing owner would violate threshold"- Can't remove owner if it would make threshold impossible"invalid threshold"- Threshold is 0 or exceeds owner count
- Owner verification: All operations verify caller is an owner
- Re-entrancy protection: Proposals marked executed before payload is returned
- Immutable execution: Executed proposals cannot be re-executed
- Threshold enforcement: Strict approval count validation
#[derive(CandidType, Deserialize, Serialize, Clone)]
pub struct Config {
pub max_payload_size: u32,
pub allowed_origins: Vec<String>,
}
#[derive(CandidType, Deserialize, Serialize, Clone)]
pub enum ConfigPayload {
UpdateConfig(Config),
AddOwner(Principal),
RemoveOwner(Principal),
ChangeThreshold(u8),
}#[derive(CandidType, Deserialize, Serialize, Clone)]
pub enum AssetPayload {
Mint { to: Principal, amount: u64 },
Burn { amount: u64 },
Freeze { account: Principal },
Unfreeze { account: Principal },
}#[derive(CandidType, Deserialize, Serialize, Clone)]
pub enum GovernancePayload {
UpgradeCanister { wasm: Vec<u8> },
SetController { controller: Principal },
UpdateSettings { settings: CanisterSettings },
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_workflow() {
let owners = vec![Principal::anonymous()];
let mut ms = Multisig::<u32>::new(owners, 1);
let id = ms.propose(Principal::anonymous(), 42).unwrap();
let result = ms.approve(Principal::anonymous(), id).unwrap();
assert_eq!(result, Some(42));
}
#[test]
fn test_serialization_round_trip() {
let owners = vec![Principal::anonymous()];
let mut ms = Multisig::<String>::new(owners, 1);
let _id = ms.propose(Principal::anonymous(), "test".to_string()).unwrap();
let bytes = ms.to_bytes().unwrap();
let restored = Multisig::<String>::from_bytes(&bytes).unwrap();
assert_eq!(ms.get_threshold(), restored.get_threshold());
assert_eq!(ms.get_owners(), restored.get_owners());
}
}Run tests with:
cargo testcandid- Candid type serializationserde- Serialization framework- Standard library collections (
BTreeMap,BTreeSet)
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request