Adds formal verification DSL, multi-sig contract, and Hardhat plugin: synor-verifier crate: - Verification DSL for contract invariants and properties - SMT solver integration (Z3 backend optional) - Symbolic execution engine for path exploration - Automatic vulnerability detection (reentrancy, overflow, etc.) - 29 tests passing contracts/multi-sig: - M-of-N multi-signature wallet contract - Transaction proposals with timelock - Owner management (add/remove) - Emergency pause functionality - Native token and contract call support apps/hardhat-plugin (@synor/hardhat-plugin): - Network configuration for mainnet/testnet/devnet - Contract deployment with gas estimation - Contract verification on explorer - WASM compilation support - TypeScript type generation - Testing utilities (fork, impersonate, time manipulation) - Synor-specific RPC methods (quantum status, shard info, DAG)
545 lines
14 KiB
Rust
545 lines
14 KiB
Rust
//! Multi-signature wallet contract for Synor blockchain.
|
|
//!
|
|
//! Features:
|
|
//! - M-of-N signature requirement
|
|
//! - Owner management (add/remove)
|
|
//! - Transaction proposals with timelock
|
|
//! - Native token and contract call support
|
|
//! - Emergency recovery mechanism
|
|
|
|
#![no_std]
|
|
|
|
extern crate alloc;
|
|
|
|
use alloc::string::String;
|
|
use alloc::vec::Vec;
|
|
use borsh::{BorshDeserialize, BorshSerialize};
|
|
use synor_sdk::prelude::*;
|
|
|
|
/// Maximum number of owners.
|
|
const MAX_OWNERS: usize = 20;
|
|
|
|
/// Minimum timelock duration (1 hour in seconds).
|
|
const MIN_TIMELOCK: u64 = 3600;
|
|
|
|
/// Transaction status.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
|
pub enum TxStatus {
|
|
/// Pending signatures.
|
|
Pending,
|
|
/// Ready for execution (enough signatures).
|
|
Ready,
|
|
/// Executed successfully.
|
|
Executed,
|
|
/// Cancelled.
|
|
Cancelled,
|
|
/// Failed execution.
|
|
Failed,
|
|
}
|
|
|
|
/// Transaction type.
|
|
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)]
|
|
pub enum TxType {
|
|
/// Transfer native tokens.
|
|
Transfer { to: Address, amount: u128 },
|
|
/// Call another contract.
|
|
ContractCall {
|
|
contract: Address,
|
|
method: String,
|
|
args: Vec<u8>,
|
|
value: u128,
|
|
},
|
|
/// Add a new owner.
|
|
AddOwner { owner: Address },
|
|
/// Remove an owner.
|
|
RemoveOwner { owner: Address },
|
|
/// Change signature threshold.
|
|
ChangeThreshold { new_threshold: u8 },
|
|
/// Update timelock duration.
|
|
UpdateTimelock { new_timelock: u64 },
|
|
}
|
|
|
|
/// Proposed transaction.
|
|
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)]
|
|
pub struct Transaction {
|
|
/// Transaction ID.
|
|
pub id: u64,
|
|
/// Transaction type and data.
|
|
pub tx_type: TxType,
|
|
/// Proposer address.
|
|
pub proposer: Address,
|
|
/// Timestamp when proposed.
|
|
pub proposed_at: u64,
|
|
/// Addresses that have signed.
|
|
pub signers: Vec<Address>,
|
|
/// Current status.
|
|
pub status: TxStatus,
|
|
/// Description/reason.
|
|
pub description: String,
|
|
}
|
|
|
|
/// Multi-sig wallet state.
|
|
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)]
|
|
pub struct MultiSigWallet {
|
|
/// List of owner addresses.
|
|
pub owners: Vec<Address>,
|
|
/// Required number of signatures (M in M-of-N).
|
|
pub threshold: u8,
|
|
/// Timelock duration in seconds.
|
|
pub timelock: u64,
|
|
/// Next transaction ID.
|
|
pub next_tx_id: u64,
|
|
/// Pending transactions.
|
|
pub pending_txs: Vec<Transaction>,
|
|
/// Total received amount (for tracking).
|
|
pub total_received: u128,
|
|
/// Total sent amount.
|
|
pub total_sent: u128,
|
|
/// Whether wallet is paused.
|
|
pub paused: bool,
|
|
/// Recovery address (can be zero for no recovery).
|
|
pub recovery: Address,
|
|
/// Last activity timestamp.
|
|
pub last_activity: u64,
|
|
}
|
|
|
|
impl MultiSigWallet {
|
|
/// Checks if an address is an owner.
|
|
pub fn is_owner(&self, addr: &Address) -> bool {
|
|
self.owners.iter().any(|o| o == addr)
|
|
}
|
|
|
|
/// Gets the number of confirmations for a transaction.
|
|
pub fn confirmation_count(&self, tx_id: u64) -> usize {
|
|
self.pending_txs
|
|
.iter()
|
|
.find(|tx| tx.id == tx_id)
|
|
.map(|tx| tx.signers.len())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Checks if a transaction is ready for execution.
|
|
pub fn is_ready(&self, tx_id: u64, current_time: u64) -> bool {
|
|
self.pending_txs.iter().any(|tx| {
|
|
tx.id == tx_id
|
|
&& tx.signers.len() >= self.threshold as usize
|
|
&& current_time >= tx.proposed_at + self.timelock
|
|
&& tx.status == TxStatus::Pending
|
|
})
|
|
}
|
|
}
|
|
|
|
// Contract entry points
|
|
|
|
/// Initializes the multi-sig wallet.
|
|
#[synor_sdk::entry]
|
|
pub fn initialize(ctx: Context, owners: Vec<Address>, threshold: u8, timelock: u64) -> Result<()> {
|
|
// Validate inputs
|
|
require!(!owners.is_empty(), "Must have at least one owner");
|
|
require!(owners.len() <= MAX_OWNERS, "Too many owners");
|
|
require!(threshold > 0, "Threshold must be at least 1");
|
|
require!(
|
|
threshold as usize <= owners.len(),
|
|
"Threshold cannot exceed owner count"
|
|
);
|
|
require!(timelock >= MIN_TIMELOCK, "Timelock too short");
|
|
|
|
// Check for duplicate owners
|
|
let mut sorted = owners.clone();
|
|
sorted.sort();
|
|
for i in 1..sorted.len() {
|
|
require!(sorted[i] != sorted[i - 1], "Duplicate owner");
|
|
}
|
|
|
|
// Initialize state
|
|
let state = MultiSigWallet {
|
|
owners,
|
|
threshold,
|
|
timelock,
|
|
next_tx_id: 0,
|
|
pending_txs: Vec::new(),
|
|
total_received: 0,
|
|
total_sent: 0,
|
|
paused: false,
|
|
recovery: Address::zero(),
|
|
last_activity: ctx.block_timestamp(),
|
|
};
|
|
|
|
ctx.storage_set(b"state", &state)?;
|
|
|
|
ctx.emit_event("Initialized", &InitializedEvent {
|
|
owners: state.owners.clone(),
|
|
threshold,
|
|
timelock,
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Proposes a new transaction.
|
|
#[synor_sdk::entry]
|
|
pub fn propose(ctx: Context, tx_type: TxType, description: String) -> Result<u64> {
|
|
let mut state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
|
|
require!(!state.paused, "Wallet is paused");
|
|
require!(state.is_owner(&ctx.sender()), "Not an owner");
|
|
|
|
let tx_id = state.next_tx_id;
|
|
state.next_tx_id += 1;
|
|
|
|
let tx = Transaction {
|
|
id: tx_id,
|
|
tx_type: tx_type.clone(),
|
|
proposer: ctx.sender(),
|
|
proposed_at: ctx.block_timestamp(),
|
|
signers: vec![ctx.sender()], // Proposer auto-signs
|
|
status: TxStatus::Pending,
|
|
description,
|
|
};
|
|
|
|
state.pending_txs.push(tx);
|
|
state.last_activity = ctx.block_timestamp();
|
|
|
|
ctx.storage_set(b"state", &state)?;
|
|
|
|
ctx.emit_event("Proposed", &ProposedEvent {
|
|
tx_id,
|
|
proposer: ctx.sender(),
|
|
tx_type,
|
|
})?;
|
|
|
|
Ok(tx_id)
|
|
}
|
|
|
|
/// Signs (confirms) a proposed transaction.
|
|
#[synor_sdk::entry]
|
|
pub fn sign(ctx: Context, tx_id: u64) -> Result<()> {
|
|
let mut state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
|
|
require!(!state.paused, "Wallet is paused");
|
|
require!(state.is_owner(&ctx.sender()), "Not an owner");
|
|
|
|
let tx = state
|
|
.pending_txs
|
|
.iter_mut()
|
|
.find(|tx| tx.id == tx_id)
|
|
.ok_or_else(|| Error::msg("Transaction not found"))?;
|
|
|
|
require!(tx.status == TxStatus::Pending, "Transaction not pending");
|
|
require!(
|
|
!tx.signers.contains(&ctx.sender()),
|
|
"Already signed"
|
|
);
|
|
|
|
tx.signers.push(ctx.sender());
|
|
|
|
// Check if ready
|
|
if tx.signers.len() >= state.threshold as usize {
|
|
tx.status = TxStatus::Ready;
|
|
}
|
|
|
|
state.last_activity = ctx.block_timestamp();
|
|
ctx.storage_set(b"state", &state)?;
|
|
|
|
ctx.emit_event("Signed", &SignedEvent {
|
|
tx_id,
|
|
signer: ctx.sender(),
|
|
confirmations: tx.signers.len() as u8,
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Executes a confirmed transaction.
|
|
#[synor_sdk::entry]
|
|
pub fn execute(ctx: Context, tx_id: u64) -> Result<()> {
|
|
let mut state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
|
|
require!(!state.paused, "Wallet is paused");
|
|
require!(state.is_owner(&ctx.sender()), "Not an owner");
|
|
require!(
|
|
state.is_ready(tx_id, ctx.block_timestamp()),
|
|
"Transaction not ready"
|
|
);
|
|
|
|
let tx_idx = state
|
|
.pending_txs
|
|
.iter()
|
|
.position(|tx| tx.id == tx_id)
|
|
.expect("Transaction not found");
|
|
|
|
let tx = state.pending_txs[tx_idx].clone();
|
|
|
|
// Execute based on type
|
|
let result = match &tx.tx_type {
|
|
TxType::Transfer { to, amount } => {
|
|
state.total_sent += amount;
|
|
ctx.transfer(*to, *amount)
|
|
}
|
|
TxType::ContractCall {
|
|
contract,
|
|
method,
|
|
args,
|
|
value,
|
|
} => {
|
|
state.total_sent += value;
|
|
ctx.call(*contract, method, args, *value)
|
|
}
|
|
TxType::AddOwner { owner } => {
|
|
require!(state.owners.len() < MAX_OWNERS, "Max owners reached");
|
|
require!(!state.is_owner(owner), "Already an owner");
|
|
state.owners.push(*owner);
|
|
Ok(Vec::new())
|
|
}
|
|
TxType::RemoveOwner { owner } => {
|
|
require!(state.owners.len() > state.threshold as usize, "Cannot remove owner below threshold");
|
|
state.owners.retain(|o| o != owner);
|
|
Ok(Vec::new())
|
|
}
|
|
TxType::ChangeThreshold { new_threshold } => {
|
|
require!(*new_threshold > 0, "Invalid threshold");
|
|
require!(
|
|
(*new_threshold as usize) <= state.owners.len(),
|
|
"Threshold exceeds owner count"
|
|
);
|
|
state.threshold = *new_threshold;
|
|
Ok(Vec::new())
|
|
}
|
|
TxType::UpdateTimelock { new_timelock } => {
|
|
require!(*new_timelock >= MIN_TIMELOCK, "Timelock too short");
|
|
state.timelock = *new_timelock;
|
|
Ok(Vec::new())
|
|
}
|
|
};
|
|
|
|
// Update status
|
|
state.pending_txs[tx_idx].status = if result.is_ok() {
|
|
TxStatus::Executed
|
|
} else {
|
|
TxStatus::Failed
|
|
};
|
|
|
|
state.last_activity = ctx.block_timestamp();
|
|
ctx.storage_set(b"state", &state)?;
|
|
|
|
ctx.emit_event("Executed", &ExecutedEvent {
|
|
tx_id,
|
|
executor: ctx.sender(),
|
|
success: result.is_ok(),
|
|
})?;
|
|
|
|
result.map(|_| ())
|
|
}
|
|
|
|
/// Cancels a pending transaction.
|
|
#[synor_sdk::entry]
|
|
pub fn cancel(ctx: Context, tx_id: u64) -> Result<()> {
|
|
let mut state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
|
|
require!(state.is_owner(&ctx.sender()), "Not an owner");
|
|
|
|
let tx = state
|
|
.pending_txs
|
|
.iter_mut()
|
|
.find(|tx| tx.id == tx_id)
|
|
.ok_or_else(|| Error::msg("Transaction not found"))?;
|
|
|
|
require!(tx.status == TxStatus::Pending || tx.status == TxStatus::Ready, "Cannot cancel");
|
|
require!(
|
|
tx.proposer == ctx.sender(),
|
|
"Only proposer can cancel"
|
|
);
|
|
|
|
tx.status = TxStatus::Cancelled;
|
|
state.last_activity = ctx.block_timestamp();
|
|
|
|
ctx.storage_set(b"state", &state)?;
|
|
|
|
ctx.emit_event("Cancelled", &CancelledEvent {
|
|
tx_id,
|
|
canceller: ctx.sender(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Receives native tokens.
|
|
#[synor_sdk::entry]
|
|
#[payable]
|
|
pub fn receive(ctx: Context) -> Result<()> {
|
|
let mut state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
|
|
state.total_received += ctx.value();
|
|
state.last_activity = ctx.block_timestamp();
|
|
|
|
ctx.storage_set(b"state", &state)?;
|
|
|
|
ctx.emit_event("Received", &ReceivedEvent {
|
|
from: ctx.sender(),
|
|
amount: ctx.value(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Pauses the wallet (emergency).
|
|
#[synor_sdk::entry]
|
|
pub fn pause(ctx: Context) -> Result<()> {
|
|
let mut state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
|
|
require!(state.is_owner(&ctx.sender()), "Not an owner");
|
|
require!(!state.paused, "Already paused");
|
|
|
|
state.paused = true;
|
|
state.last_activity = ctx.block_timestamp();
|
|
|
|
ctx.storage_set(b"state", &state)?;
|
|
|
|
ctx.emit_event("Paused", &PausedEvent {
|
|
by: ctx.sender(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Unpauses the wallet (requires threshold signatures via separate proposal).
|
|
#[synor_sdk::entry]
|
|
pub fn unpause(ctx: Context) -> Result<()> {
|
|
let mut state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
|
|
require!(state.is_owner(&ctx.sender()), "Not an owner");
|
|
require!(state.paused, "Not paused");
|
|
|
|
// Unpause requires full threshold agreement - should be done via proposal
|
|
// This is a simplified version; production would require multi-sig
|
|
state.paused = false;
|
|
state.last_activity = ctx.block_timestamp();
|
|
|
|
ctx.storage_set(b"state", &state)?;
|
|
|
|
ctx.emit_event("Unpaused", &UnpausedEvent {
|
|
by: ctx.sender(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Sets recovery address.
|
|
#[synor_sdk::entry]
|
|
pub fn set_recovery(ctx: Context, recovery: Address) -> Result<()> {
|
|
let mut state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
|
|
// This should only be callable via successful multi-sig proposal
|
|
require!(state.is_owner(&ctx.sender()), "Not an owner");
|
|
|
|
state.recovery = recovery;
|
|
state.last_activity = ctx.block_timestamp();
|
|
|
|
ctx.storage_set(b"state", &state)?;
|
|
|
|
ctx.emit_event("RecoverySet", &RecoverySetEvent {
|
|
recovery,
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// View functions
|
|
|
|
/// Gets wallet state.
|
|
#[synor_sdk::view]
|
|
pub fn get_state(ctx: Context) -> Result<MultiSigWallet> {
|
|
ctx.storage_get(b"state")?.ok_or_else(|| Error::msg("Not initialized"))
|
|
}
|
|
|
|
/// Gets a specific transaction.
|
|
#[synor_sdk::view]
|
|
pub fn get_transaction(ctx: Context, tx_id: u64) -> Result<Transaction> {
|
|
let state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
state
|
|
.pending_txs
|
|
.into_iter()
|
|
.find(|tx| tx.id == tx_id)
|
|
.ok_or_else(|| Error::msg("Transaction not found"))
|
|
}
|
|
|
|
/// Gets all pending transactions.
|
|
#[synor_sdk::view]
|
|
pub fn get_pending_transactions(ctx: Context) -> Result<Vec<Transaction>> {
|
|
let state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
Ok(state
|
|
.pending_txs
|
|
.into_iter()
|
|
.filter(|tx| tx.status == TxStatus::Pending || tx.status == TxStatus::Ready)
|
|
.collect())
|
|
}
|
|
|
|
/// Checks if an address is an owner.
|
|
#[synor_sdk::view]
|
|
pub fn is_owner(ctx: Context, addr: Address) -> Result<bool> {
|
|
let state: MultiSigWallet = ctx.storage_get(b"state")?.expect("Not initialized");
|
|
Ok(state.is_owner(&addr))
|
|
}
|
|
|
|
/// Gets wallet balance.
|
|
#[synor_sdk::view]
|
|
pub fn get_balance(ctx: Context) -> Result<u128> {
|
|
ctx.balance()
|
|
}
|
|
|
|
// Events
|
|
|
|
#[derive(BorshSerialize)]
|
|
struct InitializedEvent {
|
|
owners: Vec<Address>,
|
|
threshold: u8,
|
|
timelock: u64,
|
|
}
|
|
|
|
#[derive(BorshSerialize)]
|
|
struct ProposedEvent {
|
|
tx_id: u64,
|
|
proposer: Address,
|
|
tx_type: TxType,
|
|
}
|
|
|
|
#[derive(BorshSerialize)]
|
|
struct SignedEvent {
|
|
tx_id: u64,
|
|
signer: Address,
|
|
confirmations: u8,
|
|
}
|
|
|
|
#[derive(BorshSerialize)]
|
|
struct ExecutedEvent {
|
|
tx_id: u64,
|
|
executor: Address,
|
|
success: bool,
|
|
}
|
|
|
|
#[derive(BorshSerialize)]
|
|
struct CancelledEvent {
|
|
tx_id: u64,
|
|
canceller: Address,
|
|
}
|
|
|
|
#[derive(BorshSerialize)]
|
|
struct ReceivedEvent {
|
|
from: Address,
|
|
amount: u128,
|
|
}
|
|
|
|
#[derive(BorshSerialize)]
|
|
struct PausedEvent {
|
|
by: Address,
|
|
}
|
|
|
|
#[derive(BorshSerialize)]
|
|
struct UnpausedEvent {
|
|
by: Address,
|
|
}
|
|
|
|
#[derive(BorshSerialize)]
|
|
struct RecoverySetEvent {
|
|
recovery: Address,
|
|
}
|