//! 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
, /// 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
) -> Result { 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::new(2, vec![signer1, signer2, signer3]) } /// Creates a 3-of-5 multisig. pub fn three_of_five(signers: [Address; 5]) -> Result { Self::new(3, signers.to_vec()) } /// Creates an N-of-N multisig (all must sign). pub fn unanimous(signers: Vec
) -> Result { 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 }, /// 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 }, } /// 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
, /// Description/reason for this transaction. pub description: String, /// When executed (if applicable). pub executed_at: Option, /// Who executed (if applicable). pub executed_by: Option
, } 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(¤t_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, /// Executed transaction history (limited). pub executed_transactions: Vec, /// 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 { // 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 { // 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, by_signer: HashMap>, } 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); } }