813 lines
25 KiB
Rust
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(¤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<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);
|
|
}
|
|
}
|