synor/crates/synor-governance/src/multisig.rs
2026-01-08 06:23:23 +05:30

813 lines
25 KiB
Rust

//! Multi-signature wallet support for Synor.
//!
//! Implements N-of-M threshold signatures where:
//! - M = total number of signers
//! - N = required signatures to execute
//!
//! # Example: 2-of-3 Multisig
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────┐
//! │ 2-of-3 MULTISIG WALLET │
//! ├─────────────────────────────────────────────────────────────┤
//! │ │
//! │ Signers: [Founder] [Advisor A] [Advisor B] │
//! │ │
//! │ ┌──────────────┐ │
//! │ │ Transaction │ "Send 100K SYNOR to Developer" │
//! │ │ Pending │ │
//! │ └──────┬───────┘ │
//! │ │ │
//! │ ▼ │
//! │ Founder signs ✓ │
//! │ Advisor A signs ✓ │
//! │ Advisor B (not needed - 2 signatures reached) │
//! │ │ │
//! │ ▼ │
//! │ ┌──────────────┐ │
//! │ │ EXECUTED │ Transaction sent! │
//! │ └──────────────┘ │
//! │ │
//! └─────────────────────────────────────────────────────────────┘
//! ```
use borsh::{BorshDeserialize, BorshSerialize};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use synor_types::{Address, Hash256};
use thiserror::Error;
/// Unique identifier for a multisig transaction.
pub type MultisigTxId = Hash256;
/// Multisig configuration.
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
pub struct MultisigConfig {
/// Required number of signatures.
pub threshold: u32,
/// List of authorized signers.
pub signers: Vec<Address>,
/// Transaction expiry time in seconds (0 = no expiry).
pub tx_expiry_seconds: u64,
/// Whether the config can be changed.
pub mutable: bool,
}
impl MultisigConfig {
/// Creates a new multisig configuration.
pub fn new(threshold: u32, signers: Vec<Address>) -> Result<Self, MultisigError> {
if threshold == 0 {
return Err(MultisigError::InvalidThreshold);
}
if signers.is_empty() {
return Err(MultisigError::NoSigners);
}
if threshold as usize > signers.len() {
return Err(MultisigError::ThresholdTooHigh);
}
// Check for duplicate signers
let unique: HashSet<_> = signers.iter().collect();
if unique.len() != signers.len() {
return Err(MultisigError::DuplicateSigner);
}
Ok(MultisigConfig {
threshold,
signers,
tx_expiry_seconds: 7 * 24 * 60 * 60, // 1 week default
mutable: true,
})
}
/// Creates a 2-of-3 multisig.
pub fn two_of_three(
signer1: Address,
signer2: Address,
signer3: Address,
) -> Result<Self, MultisigError> {
Self::new(2, vec![signer1, signer2, signer3])
}
/// Creates a 3-of-5 multisig.
pub fn three_of_five(signers: [Address; 5]) -> Result<Self, MultisigError> {
Self::new(3, signers.to_vec())
}
/// Creates an N-of-N multisig (all must sign).
pub fn unanimous(signers: Vec<Address>) -> Result<Self, MultisigError> {
let n = signers.len() as u32;
Self::new(n, signers)
}
/// Checks if an address is a signer.
pub fn is_signer(&self, address: &Address) -> bool {
self.signers.contains(address)
}
/// Returns the number of signers.
pub fn signer_count(&self) -> usize {
self.signers.len()
}
/// Returns threshold description (e.g., "2-of-3").
pub fn description(&self) -> String {
format!("{}-of-{}", self.threshold, self.signers.len())
}
}
/// State of a multisig transaction.
#[derive(
Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize,
)]
pub enum MultisigTxState {
/// Transaction is pending signatures.
Pending,
/// Transaction has enough signatures and can be executed.
Ready,
/// Transaction has been executed.
Executed,
/// Transaction was cancelled.
Cancelled,
/// Transaction expired.
Expired,
}
/// Type of multisig transaction.
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
pub enum MultisigTxType {
/// Transfer tokens to an address.
Transfer { to: Address, amount: u64 },
/// Add a new signer.
AddSigner { new_signer: Address },
/// Remove a signer.
RemoveSigner { signer: Address },
/// Change the threshold.
ChangeThreshold { new_threshold: u32 },
/// Execute a raw transaction.
RawTransaction { tx_data: Vec<u8> },
/// Create a vesting contract.
CreateVesting {
beneficiary: Address,
amount: u64,
cliff_months: u32,
vesting_months: u32,
description: String,
},
/// Approve a DAO proposal.
ApproveProposal { proposal_id: Hash256 },
/// Custom action with arbitrary data.
Custom { action_type: String, data: Vec<u8> },
}
/// A pending multisig transaction.
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
pub struct MultisigTransaction {
/// Unique transaction ID.
pub id: MultisigTxId,
/// Wallet this transaction belongs to.
pub wallet_id: Hash256,
/// Transaction type and parameters.
pub tx_type: MultisigTxType,
/// Who proposed this transaction.
pub proposer: Address,
/// When the transaction was proposed (Unix timestamp).
pub proposed_at: u64,
/// When the transaction expires (Unix timestamp, 0 = never).
pub expires_at: u64,
/// Current state.
pub state: MultisigTxState,
/// Addresses that have signed.
pub signatures: Vec<Address>,
/// Description/reason for this transaction.
pub description: String,
/// When executed (if applicable).
pub executed_at: Option<u64>,
/// Who executed (if applicable).
pub executed_by: Option<Address>,
}
impl MultisigTransaction {
/// Creates a new pending transaction.
pub fn new(
wallet_id: Hash256,
tx_type: MultisigTxType,
proposer: Address,
current_time: u64,
expiry_seconds: u64,
description: String,
) -> Self {
// Generate ID from content
let mut id_data = Vec::new();
id_data.extend_from_slice(wallet_id.as_bytes());
id_data.extend_from_slice(&current_time.to_le_bytes());
id_data.extend_from_slice(proposer.payload());
if let Ok(tx_bytes) = borsh::to_vec(&tx_type) {
id_data.extend_from_slice(&tx_bytes);
}
let id = Hash256::blake3(&id_data);
let expires_at = if expiry_seconds > 0 {
current_time + expiry_seconds
} else {
0
};
MultisigTransaction {
id,
wallet_id,
tx_type,
proposer: proposer.clone(),
proposed_at: current_time,
expires_at,
state: MultisigTxState::Pending,
signatures: vec![proposer], // Proposer auto-signs
description,
executed_at: None,
executed_by: None,
}
}
/// Checks if the transaction has expired.
pub fn is_expired(&self, current_time: u64) -> bool {
self.expires_at > 0 && current_time > self.expires_at
}
/// Returns the number of signatures.
pub fn signature_count(&self) -> usize {
self.signatures.len()
}
/// Checks if an address has signed.
pub fn has_signed(&self, address: &Address) -> bool {
self.signatures.contains(address)
}
/// Checks if the transaction is ready to execute.
pub fn is_ready(&self, threshold: u32) -> bool {
self.signatures.len() >= threshold as usize
}
}
/// Multisig errors.
#[derive(Debug, Error)]
pub enum MultisigError {
#[error("Wallet not found")]
WalletNotFound,
#[error("Transaction not found")]
TransactionNotFound,
#[error("Not authorized (not a signer)")]
NotAuthorized,
#[error("Already signed this transaction")]
AlreadySigned,
#[error("Transaction already executed")]
AlreadyExecuted,
#[error("Transaction cancelled")]
Cancelled,
#[error("Transaction expired")]
Expired,
#[error("Not enough signatures")]
NotEnoughSignatures,
#[error("Invalid threshold (must be > 0)")]
InvalidThreshold,
#[error("Threshold higher than signer count")]
ThresholdTooHigh,
#[error("No signers provided")]
NoSigners,
#[error("Duplicate signer in list")]
DuplicateSigner,
#[error("Cannot remove signer: would break threshold")]
CannotRemoveSigner,
#[error("Wallet configuration is immutable")]
Immutable,
#[error("Insufficient balance")]
InsufficientBalance,
}
/// A multisig wallet.
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
pub struct MultisigWallet {
/// Unique wallet ID.
pub id: Hash256,
/// Wallet name/label.
pub name: String,
/// Configuration.
pub config: MultisigConfig,
/// Pending transactions.
pub pending_transactions: HashMap<MultisigTxId, MultisigTransaction>,
/// Executed transaction history (limited).
pub executed_transactions: Vec<MultisigTxId>,
/// Token balance (tracked separately from UTXO for simplicity).
pub balance: u64,
/// Creation timestamp.
pub created_at: u64,
/// Nonce for transaction ordering.
pub nonce: u64,
}
impl MultisigWallet {
/// Creates a new multisig wallet.
pub fn new(name: String, config: MultisigConfig, created_at: u64) -> Self {
// Generate wallet ID
let mut id_data = Vec::new();
id_data.extend_from_slice(name.as_bytes());
id_data.extend_from_slice(&created_at.to_le_bytes());
for signer in &config.signers {
id_data.extend_from_slice(signer.payload());
}
let id = Hash256::blake3(&id_data);
MultisigWallet {
id,
name,
config,
pending_transactions: HashMap::new(),
executed_transactions: Vec::new(),
balance: 0,
created_at,
nonce: 0,
}
}
/// Proposes a new transaction.
pub fn propose(
&mut self,
tx_type: MultisigTxType,
proposer: &Address,
current_time: u64,
description: String,
) -> Result<MultisigTxId, MultisigError> {
// Check proposer is a signer
if !self.config.is_signer(proposer) {
return Err(MultisigError::NotAuthorized);
}
let tx = MultisigTransaction::new(
self.id,
tx_type,
proposer.clone(),
current_time,
self.config.tx_expiry_seconds,
description,
);
let tx_id = tx.id;
self.pending_transactions.insert(tx_id, tx);
self.nonce += 1;
Ok(tx_id)
}
/// Signs a pending transaction.
pub fn sign(
&mut self,
tx_id: &MultisigTxId,
signer: &Address,
current_time: u64,
) -> Result<MultisigTxState, MultisigError> {
// Check signer is authorized
if !self.config.is_signer(signer) {
return Err(MultisigError::NotAuthorized);
}
let tx = self
.pending_transactions
.get_mut(tx_id)
.ok_or(MultisigError::TransactionNotFound)?;
// Check transaction state
if tx.state == MultisigTxState::Executed {
return Err(MultisigError::AlreadyExecuted);
}
if tx.state == MultisigTxState::Cancelled {
return Err(MultisigError::Cancelled);
}
if tx.is_expired(current_time) {
tx.state = MultisigTxState::Expired;
return Err(MultisigError::Expired);
}
// Check if already signed
if tx.has_signed(signer) {
return Err(MultisigError::AlreadySigned);
}
// Add signature
tx.signatures.push(signer.clone());
// Update state if threshold reached
if tx.is_ready(self.config.threshold) {
tx.state = MultisigTxState::Ready;
}
Ok(tx.state)
}
/// Executes a ready transaction.
pub fn execute(
&mut self,
tx_id: &MultisigTxId,
executor: &Address,
current_time: u64,
) -> Result<&MultisigTransaction, MultisigError> {
// Check executor is a signer
if !self.config.is_signer(executor) {
return Err(MultisigError::NotAuthorized);
}
let tx = self
.pending_transactions
.get_mut(tx_id)
.ok_or(MultisigError::TransactionNotFound)?;
// Check transaction state
if tx.state == MultisigTxState::Executed {
return Err(MultisigError::AlreadyExecuted);
}
if tx.is_expired(current_time) {
tx.state = MultisigTxState::Expired;
return Err(MultisigError::Expired);
}
if !tx.is_ready(self.config.threshold) {
return Err(MultisigError::NotEnoughSignatures);
}
// Validate transaction-specific requirements
match &tx.tx_type {
MultisigTxType::Transfer { amount, .. } => {
if *amount > self.balance {
return Err(MultisigError::InsufficientBalance);
}
self.balance -= amount;
}
MultisigTxType::ChangeThreshold { new_threshold } => {
if *new_threshold as usize > self.config.signers.len() {
return Err(MultisigError::ThresholdTooHigh);
}
if !self.config.mutable {
return Err(MultisigError::Immutable);
}
self.config.threshold = *new_threshold;
}
MultisigTxType::AddSigner { new_signer } => {
if !self.config.mutable {
return Err(MultisigError::Immutable);
}
if !self.config.signers.contains(new_signer) {
self.config.signers.push(new_signer.clone());
}
}
MultisigTxType::RemoveSigner { signer } => {
if !self.config.mutable {
return Err(MultisigError::Immutable);
}
// Check this won't break threshold
if self.config.signers.len() - 1 < self.config.threshold as usize {
return Err(MultisigError::CannotRemoveSigner);
}
self.config.signers.retain(|s| s != signer);
}
_ => {
// Other transaction types handled externally
}
}
// Mark as executed
tx.state = MultisigTxState::Executed;
tx.executed_at = Some(current_time);
tx.executed_by = Some(executor.clone());
// Move to executed history
self.executed_transactions.push(*tx_id);
// Limit history size
while self.executed_transactions.len() > 100 {
self.executed_transactions.remove(0);
}
Ok(self.pending_transactions.get(tx_id).unwrap())
}
/// Cancels a pending transaction.
pub fn cancel(
&mut self,
tx_id: &MultisigTxId,
canceller: &Address,
) -> Result<(), MultisigError> {
// Only proposer can cancel
let tx = self
.pending_transactions
.get_mut(tx_id)
.ok_or(MultisigError::TransactionNotFound)?;
if &tx.proposer != canceller {
return Err(MultisigError::NotAuthorized);
}
if tx.state == MultisigTxState::Executed {
return Err(MultisigError::AlreadyExecuted);
}
tx.state = MultisigTxState::Cancelled;
Ok(())
}
/// Deposits tokens to the wallet.
pub fn deposit(&mut self, amount: u64) {
self.balance += amount;
}
/// Gets a pending transaction.
pub fn get_transaction(&self, tx_id: &MultisigTxId) -> Option<&MultisigTransaction> {
self.pending_transactions.get(tx_id)
}
/// Gets all pending transactions.
pub fn pending(&self) -> Vec<&MultisigTransaction> {
self.pending_transactions
.values()
.filter(|tx| tx.state == MultisigTxState::Pending || tx.state == MultisigTxState::Ready)
.collect()
}
/// Gets transactions awaiting a specific signer.
pub fn awaiting_signature(&self, signer: &Address) -> Vec<&MultisigTransaction> {
self.pending_transactions
.values()
.filter(|tx| {
(tx.state == MultisigTxState::Pending || tx.state == MultisigTxState::Ready)
&& !tx.has_signed(signer)
})
.collect()
}
/// Cleans up expired transactions.
pub fn cleanup_expired(&mut self, current_time: u64) -> usize {
let expired: Vec<_> = self
.pending_transactions
.iter()
.filter(|(_, tx)| tx.is_expired(current_time) && tx.state == MultisigTxState::Pending)
.map(|(id, _)| *id)
.collect();
for id in &expired {
if let Some(tx) = self.pending_transactions.get_mut(id) {
tx.state = MultisigTxState::Expired;
}
}
expired.len()
}
/// Returns wallet summary.
pub fn summary(&self) -> MultisigWalletSummary {
let pending: Vec<_> = self.pending().into_iter().collect();
MultisigWalletSummary {
id: self.id,
name: self.name.clone(),
config_description: self.config.description(),
signer_count: self.config.signers.len(),
threshold: self.config.threshold,
balance: self.balance,
pending_count: pending.len(),
ready_count: pending
.iter()
.filter(|tx| tx.state == MultisigTxState::Ready)
.count(),
total_executed: self.executed_transactions.len(),
}
}
}
/// Summary of a multisig wallet.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MultisigWalletSummary {
pub id: Hash256,
pub name: String,
pub config_description: String,
pub signer_count: usize,
pub threshold: u32,
pub balance: u64,
pub pending_count: usize,
pub ready_count: usize,
pub total_executed: usize,
}
/// Manages multiple multisig wallets.
pub struct MultisigManager {
wallets: HashMap<Hash256, MultisigWallet>,
by_signer: HashMap<Address, Vec<Hash256>>,
}
impl MultisigManager {
/// Creates a new multisig manager.
pub fn new() -> Self {
MultisigManager {
wallets: HashMap::new(),
by_signer: HashMap::new(),
}
}
/// Creates a new multisig wallet.
pub fn create_wallet(
&mut self,
name: String,
config: MultisigConfig,
created_at: u64,
) -> Hash256 {
let wallet = MultisigWallet::new(name, config.clone(), created_at);
let id = wallet.id;
// Index by signers
for signer in &config.signers {
self.by_signer.entry(signer.clone()).or_default().push(id);
}
self.wallets.insert(id, wallet);
id
}
/// Gets a wallet by ID.
pub fn get(&self, id: &Hash256) -> Option<&MultisigWallet> {
self.wallets.get(id)
}
/// Gets mutable wallet reference.
pub fn get_mut(&mut self, id: &Hash256) -> Option<&mut MultisigWallet> {
self.wallets.get_mut(id)
}
/// Gets all wallets for a signer.
pub fn wallets_for_signer(&self, signer: &Address) -> Vec<&MultisigWallet> {
self.by_signer
.get(signer)
.map(|ids| ids.iter().filter_map(|id| self.wallets.get(id)).collect())
.unwrap_or_default()
}
/// Gets all transactions awaiting a signer across all wallets.
pub fn awaiting_signature(
&self,
signer: &Address,
) -> Vec<(&MultisigWallet, &MultisigTransaction)> {
self.wallets_for_signer(signer)
.into_iter()
.flat_map(|wallet| {
wallet
.awaiting_signature(signer)
.into_iter()
.map(move |tx| (wallet, tx))
})
.collect()
}
/// Returns all wallets.
pub fn all_wallets(&self) -> Vec<&MultisigWallet> {
self.wallets.values().collect()
}
}
impl Default for MultisigManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use synor_types::address::AddressType;
fn test_address(n: u8) -> Address {
let mut bytes = [0u8; 32];
bytes[0] = n;
Address::from_parts(synor_types::Network::Devnet, AddressType::P2PKH, bytes)
}
#[test]
fn test_multisig_config() {
let config =
MultisigConfig::two_of_three(test_address(1), test_address(2), test_address(3))
.unwrap();
assert_eq!(config.threshold, 2);
assert_eq!(config.signer_count(), 3);
assert_eq!(config.description(), "2-of-3");
assert!(config.is_signer(&test_address(1)));
assert!(!config.is_signer(&test_address(4)));
}
#[test]
fn test_invalid_config() {
// Threshold > signers
let result = MultisigConfig::new(3, vec![test_address(1), test_address(2)]);
assert!(matches!(result, Err(MultisigError::ThresholdTooHigh)));
// Zero threshold
let result = MultisigConfig::new(0, vec![test_address(1)]);
assert!(matches!(result, Err(MultisigError::InvalidThreshold)));
// Duplicate signer
let result = MultisigConfig::new(1, vec![test_address(1), test_address(1)]);
assert!(matches!(result, Err(MultisigError::DuplicateSigner)));
}
#[test]
fn test_multisig_flow() {
let signer1 = test_address(1);
let signer2 = test_address(2);
let signer3 = test_address(3);
let recipient = test_address(10);
let config =
MultisigConfig::two_of_three(signer1.clone(), signer2.clone(), signer3.clone())
.unwrap();
let mut wallet = MultisigWallet::new("Test Wallet".to_string(), config, 0);
wallet.deposit(1_000_000);
// Propose a transfer
let tx_id = wallet
.propose(
MultisigTxType::Transfer {
to: recipient,
amount: 100_000,
},
&signer1,
100,
"Pay developer".to_string(),
)
.unwrap();
// Check proposer auto-signed
let tx = wallet.get_transaction(&tx_id).unwrap();
assert_eq!(tx.signature_count(), 1);
assert_eq!(tx.state, MultisigTxState::Pending);
// Second signature
let state = wallet.sign(&tx_id, &signer2, 101).unwrap();
assert_eq!(state, MultisigTxState::Ready);
// Execute
let executed_tx = wallet.execute(&tx_id, &signer2, 102).unwrap();
assert_eq!(executed_tx.state, MultisigTxState::Executed);
assert_eq!(wallet.balance, 900_000);
}
#[test]
fn test_not_enough_signatures() {
let signer1 = test_address(1);
let signer2 = test_address(2);
let signer3 = test_address(3);
let config =
MultisigConfig::two_of_three(signer1.clone(), signer2.clone(), signer3.clone())
.unwrap();
let mut wallet = MultisigWallet::new("Test".to_string(), config, 0);
wallet.deposit(1_000_000);
let tx_id = wallet
.propose(
MultisigTxType::Transfer {
to: test_address(10),
amount: 100,
},
&signer1,
100,
"Test".to_string(),
)
.unwrap();
// Try to execute with only 1 signature
let result = wallet.execute(&tx_id, &signer1, 101);
assert!(matches!(result, Err(MultisigError::NotEnoughSignatures)));
}
#[test]
fn test_multisig_manager() {
let mut manager = MultisigManager::new();
let signer = test_address(1);
let config = MultisigConfig::new(1, vec![signer.clone()]).unwrap();
let wallet_id = manager.create_wallet("Test".to_string(), config, 0);
assert!(manager.get(&wallet_id).is_some());
assert_eq!(manager.wallets_for_signer(&signer).len(), 1);
}
}