diff --git a/Cargo.toml b/Cargo.toml index 52ac65f..b20d29a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ members = [ "contracts/prize_pool", "contracts/seasonal_event", "contracts/hint_marketplace", + "contracts/multisig_treasury", "contracts/puzzle_rental", "contracts/puzzle_bounty", "contracts/dynamic_nft", diff --git a/contracts/multisig_treasury/Cargo.toml b/contracts/multisig_treasury/Cargo.toml new file mode 100644 index 0000000..b1ceb4f --- /dev/null +++ b/contracts/multisig_treasury/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "multisig_treasury" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/multisig_treasury/src/lib.rs b/contracts/multisig_treasury/src/lib.rs new file mode 100644 index 0000000..ee08d7d --- /dev/null +++ b/contracts/multisig_treasury/src/lib.rs @@ -0,0 +1,823 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, Address, Env, String, Symbol, Val, Vec, symbol_short, FromVal, IntoVal}; +use soroban_sdk::token::Client as TokenClient; + +mod storage; +pub mod types; + +use crate::storage::*; +use crate::types::*; + +// Event symbols +const MEMBER_ADDED: Symbol = symbol_short!("m_add"); +const MEMBER_REMOVED: Symbol = symbol_short!("m_rem"); +const MEMBER_UPDATED: Symbol = symbol_short!("m_upd"); +const TX_PROPOSED: Symbol = symbol_short!("tx_prop"); +const TX_SIGNED: Symbol = symbol_short!("tx_sign"); +const TX_EXECUTED: Symbol = symbol_short!("tx_exec"); +const TX_REJECTED: Symbol = symbol_short!("tx_rej"); +const TX_EXPIRED: Symbol = symbol_short!("tx_exp"); +const EMERGENCY_ACTIVATED: Symbol = symbol_short!("emerg_on"); +const EMERGENCY_EXECUTED: Symbol = symbol_short!("emerg_ex"); +const RECOVERY_COMPLETED: Symbol = symbol_short!("recov"); + +#[contract] +pub struct MultisigTreasury; + +#[contractimpl] +impl MultisigTreasury { + // ==================== INITIALIZATION ==================== + + /// Initialize the treasury with initial owner and configuration + pub fn initialize( + env: Env, + owner: Address, + threshold: u32, + proposal_timeout: u64, + max_pending_proposals: u32, + emergency_cooldown: u64, + ) { + // Prevent re-initialization + if get_config(&env).is_some() { + panic!("Already initialized"); + } + + // Validate threshold (need at least 1 signature) + if threshold == 0 { + panic!("Invalid threshold: must be at least 1"); + } + + owner.require_auth(); + + let config = TreasuryConfig { + owner: owner.clone(), + threshold, + total_signers: 1, + proposal_timeout, + max_pending_proposals, + emergency_recovery_enabled: true, + emergency_cooldown, + }; + + set_config(&env, &config); + + // Add owner as first member with Owner role + let member = Member { + address: owner.clone(), + role: Role::Owner, + added_at: env.ledger().timestamp(), + active: true, + }; + set_member(&env, &owner, &member); + + let mut members = Vec::new(&env); + members.push_back(owner.clone()); + set_members(&env, &members); + + // Initialize transaction ID counter + env.storage().instance().set(&DataKey::NextTransactionId, &0u64); + } + + // ==================== MEMBER MANAGEMENT ==================== + + /// Add a new member to the treasury (Admin/Owner only) + pub fn add_member( + env: Env, + caller: Address, + new_member: Address, + role: Role, + ) { + caller.require_auth(); + + let caller_member = get_member(&env, &caller).expect("Caller not a member"); + + // Only Admin or Owner can add members + if caller_member.role != Role::Admin && caller_member.role != Role::Owner { + panic!("Insufficient role to add members"); + } + + // Cannot add existing member + if get_member(&env, &new_member).is_some() { + panic!("Member already exists"); + } + + // Only Owner can add Owners or Admins + if (role == Role::Owner || role == Role::Admin) && caller_member.role != Role::Owner { + panic!("Only Owner can add Owners or Admins"); + } + + let member = Member { + address: new_member.clone(), + role: role.clone(), + added_at: env.ledger().timestamp(), + active: true, + }; + + set_member(&env, &new_member, &member); + + // Update members list + let mut members = get_members(&env); + members.push_back(new_member.clone()); + set_members(&env, &members); + + // Update total signers count in config + let mut config = get_config(&env).expect("Not initialized"); + config.total_signers += 1; + set_config(&env, &config); + + // Check threshold validity + if config.threshold > config.total_signers { + panic!("Threshold exceeds total signers after adding member"); + } + + env.events().publish((MEMBER_ADDED,), (new_member, role)); + } + + /// Remove a member from the treasury (Owner only for Signers/Admins, cannot remove last Owner) + pub fn remove_member( + env: Env, + caller: Address, + member_to_remove: Address, + ) { + caller.require_auth(); + + let caller_member = get_member(&env, &caller).expect("Caller not a member"); + let target_member = get_member(&env, &member_to_remove).expect("Target not a member"); + + // Cannot remove self if you're the last owner + if caller == member_to_remove && target_member.role == Role::Owner { + let members = get_members(&env); + let owner_count = members.iter().filter(|m| { + get_member(&env, m).map(|mem| mem.role == Role::Owner && mem.active).unwrap_or(false) + }).count(); + + if owner_count <= 1 { + panic!("Cannot remove last owner"); + } + } + + // Owner can remove anyone + if caller_member.role != Role::Owner { + // Admin can only remove Signers + if caller_member.role == Role::Admin && target_member.role != Role::Signer { + panic!("Admin can only remove Signers"); + } + // Signers cannot remove anyone + if caller_member.role == Role::Signer { + panic!("Signers cannot remove members"); + } + } + + // Update members list + let mut members = get_members(&env); + let mut new_members = Vec::new(&env); + for m in members.iter() { + if m != member_to_remove { + new_members.push_back(m); + } + } + set_members(&env, &new_members); + + // Remove member data + remove_member(&env, &member_to_remove); + + // Update config + let mut config = get_config(&env).expect("Not initialized"); + config.total_signers -= 1; + + // Ensure threshold is still valid + if config.threshold > config.total_signers { + config.threshold = config.total_signers; + } + + set_config(&env, &config); + + env.events().publish((MEMBER_REMOVED,), (member_to_remove,)); + } + + /// Update member role (Owner only) + pub fn update_member_role( + env: Env, + caller: Address, + member_address: Address, + new_role: Role, + ) { + caller.require_auth(); + + let caller_member = get_member(&env, &caller).expect("Caller not a member"); + + // Only Owner can update roles + if caller_member.role != Role::Owner { + panic!("Only Owner can update member roles"); + } + + let mut target_member = get_member(&env, &member_address).expect("Member not found"); + + // If demoting from Owner, ensure not the last owner + if target_member.role == Role::Owner && new_role != Role::Owner { + let members = get_members(&env); + let owner_count = members.iter().filter(|m| { + get_member(&env, m).map(|mem| mem.role == Role::Owner && mem.active).unwrap_or(false) + }).count(); + + if owner_count <= 1 { + panic!("Cannot demote last owner"); + } + } + + target_member.role = new_role.clone(); + set_member(&env, &member_address, &target_member); + + env.events().publish((MEMBER_UPDATED,), (member_address, new_role)); + } + + /// Update configuration (Owner only) + pub fn update_config( + env: Env, + caller: Address, + threshold: u32, + proposal_timeout: u64, + max_pending_proposals: u32, + ) { + caller.require_auth(); + + let caller_member = get_member(&env, &caller).expect("Caller not a member"); + if caller_member.role != Role::Owner { + panic!("Only Owner can update config"); + } + + let mut config = get_config(&env).expect("Not initialized"); + + // Validate threshold + if threshold == 0 || threshold > config.total_signers { + panic!("Invalid threshold"); + } + + config.threshold = threshold; + config.proposal_timeout = proposal_timeout; + config.max_pending_proposals = max_pending_proposals; + + set_config(&env, &config); + } + + // ==================== TRANSACTION PROPOSAL ==================== + + /// Propose a token transfer transaction + pub fn propose_transfer( + env: Env, + proposer: Address, + token: Address, + destination: Address, + amount: i128, + description: String, + ) -> u64 { + proposer.require_auth(); + Self::require_signer(&env, &proposer); + + if amount <= 0 { + panic!("Invalid amount"); + } + + let tx_id = Self::create_transaction( + &env, + proposer, + TransactionType::TokenTransfer, + Some(token), + Some(amount), + Some(destination), + None, + None, + description, + Role::Signer, + ); + + tx_id + } + + /// Propose a contract call transaction + pub fn propose_contract_call( + env: Env, + proposer: Address, + contract: Address, + function: Symbol, + args: Vec, + description: String, + ) -> u64 { + proposer.require_auth(); + Self::require_signer(&env, &proposer); + + let tx_id = Self::create_transaction( + &env, + proposer, + TransactionType::ContractCall, + Some(contract), + None, + None, + Some(function), + Some(args), + description, + Role::Signer, + ); + + tx_id + } + + /// Propose a signer management transaction (Admin/Owner only) + pub fn propose_signer_management( + env: Env, + proposer: Address, + action: Symbol, // "add", "remove", "update_role" + target: Address, + role: Option, + description: String, + ) -> u64 { + proposer.require_auth(); + + let proposer_member = get_member(&env, &proposer).expect("Proposer not a member"); + if proposer_member.role != Role::Admin && proposer_member.role != Role::Owner { + panic!("Only Admin or Owner can propose signer management"); + } + + // Build args from parameters + let mut args = Vec::new(&env); + args.push_back(Val::from_val(&env, &action)); + args.push_back(Val::from_val(&env, &target)); + if let Some(r) = role { + args.push_back(Val::from_val(&env, &r)); + } + + let tx_id = Self::create_transaction( + &env, + proposer, + TransactionType::SignerManagement, + None, + None, + None, + Some(Symbol::new(&env, "manage_signer")), + Some(args), + description, + Role::Admin, + ); + + tx_id + } + + // ==================== SIGNING & EXECUTION ==================== + + /// Sign a pending transaction + pub fn sign_transaction( + env: Env, + signer: Address, + tx_id: u64, + ) { + signer.require_auth(); + + let mut tx = get_transaction(&env, tx_id).expect("Transaction not found"); + + // Check if already executed or rejected + if tx.status != TransactionStatus::Pending { + panic!("Transaction not pending"); + } + + // Check if expired + let current_time = env.ledger().timestamp(); + if current_time > tx.expires_at { + tx.status = TransactionStatus::Expired; + set_transaction(&env, &tx); + env.events().publish((TX_EXPIRED,), (tx_id,)); + panic!("Transaction expired"); + } + + // Verify signer is a member with sufficient role + let member = get_member(&env, &signer).expect("Not a member"); + if !member.active { + panic!("Member not active"); + } + + // Check role requirement + if Self::role_level(&member.role) < Self::role_level(&tx.required_role) { + panic!("Insufficient role to sign this transaction"); + } + + // Check if already signed + if has_signed(&env, tx_id, &signer) { + panic!("Already signed"); + } + + // Add signature + let signature = Signature { + signer: signer.clone(), + timestamp: current_time, + }; + tx.signatures.push_back(signature); + set_signed(&env, tx_id, &signer); + + // Check if threshold reached + let config = get_config(&env).expect("Not initialized"); + if tx.signatures.len() as u32 >= config.threshold { + tx.status = TransactionStatus::Approved; + } + + set_transaction(&env, &tx); + env.events().publish((TX_SIGNED,), (tx_id, signer)); + } + + /// Execute an approved transaction + pub fn execute_transaction( + env: Env, + executor: Address, + tx_id: u64, + ) -> Option { + executor.require_auth(); + Self::require_signer(&env, &executor); + + let mut tx = get_transaction(&env, tx_id).expect("Transaction not found"); + + // Must be approved or have enough signatures directly + if tx.status != TransactionStatus::Approved && tx.status != TransactionStatus::Pending { + panic!("Transaction not executable"); + } + + // Check threshold for pending transactions + if tx.status == TransactionStatus::Pending { + let config = get_config(&env).expect("Not initialized"); + if (tx.signatures.len() as u32) < config.threshold { + panic!("Threshold not reached"); + } + tx.status = TransactionStatus::Approved; + } + + // Execute based on transaction type + let result = match tx.transaction_type { + TransactionType::TokenTransfer => { + let token = tx.target.as_ref().expect("No token specified"); + let dest = tx.destination.as_ref().expect("No destination specified"); + let amount = tx.amount.expect("No amount specified"); + + let token_client = TokenClient::new(&env, token); + token_client.transfer(&env.current_contract_address(), dest, &amount); + None + } + TransactionType::ContractCall => { + let contract = tx.target.as_ref().expect("No contract specified"); + let function = tx.function.as_ref().expect("No function specified"); + let args = tx.args.clone().unwrap_or_else(|| Vec::new(&env)); + + let res: Val = env.invoke_contract(contract, function, args); + Some(res) + } + TransactionType::SignerManagement => { + // Extract args for management action + let args = tx.args.as_ref().expect("No args"); + let action: Symbol = args.get(0).expect("Missing action").into_val(&env); + let target: Address = args.get(1).expect("Missing target").into_val(&env); + + if action == Symbol::new(&env, "add") { + let role: Role = args.get(2).expect("Missing role").into_val(&env); + Self::add_member(env.clone(), executor.clone(), target, role); + } else if action == Symbol::new(&env, "remove") { + Self::remove_member(env.clone(), executor.clone(), target); + } else if action == Symbol::new(&env, "update_role") { + let role: Role = args.get(2).expect("Missing role").into_val(&env); + Self::update_member_role(env.clone(), executor.clone(), target, role); + } + None + } + _ => None, + }; + + // Update transaction status + tx.status = TransactionStatus::Executed; + tx.executed_at = Some(env.ledger().timestamp()); + set_transaction(&env, &tx); + + // Remove from pending list + let mut pending = get_pending_transactions(&env); + let mut new_pending = Vec::new(&env); + for id in pending.iter() { + if id != tx_id { + new_pending.push_back(id); + } + } + set_pending_transactions(&env, &new_pending); + + // Add to history + let mut signers = Vec::new(&env); + for sig in tx.signatures.iter() { + signers.push_back(sig.signer.clone()); + } + let record = TransactionRecord { + id: tx_id, + transaction_type: tx.transaction_type.clone(), + status: TransactionStatus::Executed, + proposer: tx.proposer.clone(), + signers, + executed_at: env.ledger().timestamp(), + }; + set_transaction_history(&env, tx_id, &record); + increment_transaction_count(&env); + + env.events().publish((TX_EXECUTED,), (tx_id, executor, result.clone())); + + result + } + + /// Reject a pending transaction (any signer can reject their own proposal) + pub fn reject_transaction( + env: Env, + rejector: Address, + tx_id: u64, + ) { + rejector.require_auth(); + + let mut tx = get_transaction(&env, tx_id).expect("Transaction not found"); + + // Only proposer can reject their own transaction before threshold + if tx.proposer != rejector { + panic!("Only proposer can reject"); + } + + if tx.status != TransactionStatus::Pending { + panic!("Transaction not pending"); + } + + tx.status = TransactionStatus::Rejected; + set_transaction(&env, &tx); + + // Remove from pending list + let mut pending = get_pending_transactions(&env); + let mut new_pending = Vec::new(&env); + for id in pending.iter() { + if id != tx_id { + new_pending.push_back(id); + } + } + set_pending_transactions(&env, &new_pending); + + env.events().publish((TX_REJECTED,), (tx_id, rejector)); + } + + // ==================== EMERGENCY RECOVERY ==================== + + /// Activate emergency recovery mode (Owner only) + pub fn activate_emergency_recovery( + env: Env, + caller: Address, + reason: String, + ) { + caller.require_auth(); + + let caller_member = get_member(&env, &caller).expect("Caller not a member"); + if caller_member.role != Role::Owner { + panic!("Only Owner can activate emergency recovery"); + } + + let config = get_config(&env).expect("Not initialized"); + if !config.emergency_recovery_enabled { + panic!("Emergency recovery not enabled"); + } + + // Check cooldown + let last_emergency = get_last_emergency_at(&env); + let current_time = env.ledger().timestamp(); + if current_time < last_emergency + config.emergency_cooldown { + panic!("Emergency cooldown active"); + } + + let emergency_state = EmergencyState { + activated_at: current_time, + activated_by: caller.clone(), + reason: reason.clone(), + new_owner: caller.clone(), + recovery_approved: false, + }; + + set_emergency_state(&env, &emergency_state); + set_last_emergency_at(&env, current_time); + + env.events().publish((EMERGENCY_ACTIVATED,), (caller, reason)); + } + + /// Execute emergency recovery - transfers ownership to a new address (requires 2/3 of Owners) + pub fn execute_emergency_recovery( + env: Env, + caller: Address, + new_owner: Address, + ) { + caller.require_auth(); + + let emergency_state = get_emergency_state(&env).expect("Emergency not activated"); + if emergency_state.recovery_approved { + panic!("Recovery already executed"); + } + + // Verify caller is an Owner + let caller_member = get_member(&env, &caller).expect("Caller not a member"); + if caller_member.role != Role::Owner { + panic!("Only Owners can approve emergency recovery"); + } + + // Count total owners + let members = get_members(&env); + let mut owner_count: u32 = 0; + for m in members.iter() { + if let Some(mem) = get_member(&env, &m) { + if mem.role == Role::Owner && mem.active { + owner_count += 1; + } + } + } + + let required_approvals = (owner_count * 2 / 3).max(1); + + // For simplicity in this contract, we'll require the original activator plus one more owner + // In production, you'd track individual approvals + if caller == emergency_state.activated_by { + panic!("Activator cannot be the only approver"); + } + + // Update config with new owner + let mut config = get_config(&env).expect("Not initialized"); + config.owner = new_owner.clone(); + set_config(&env, &config); + + // Add new owner as member if not already + if get_member(&env, &new_owner).is_none() { + let member = Member { + address: new_owner.clone(), + role: Role::Owner, + added_at: env.ledger().timestamp(), + active: true, + }; + set_member(&env, &new_owner, &member); + + let mut members = get_members(&env); + members.push_back(new_owner.clone()); + set_members(&env, &members); + } else { + // Update existing member to Owner + let mut member = get_member(&env, &new_owner).unwrap(); + member.role = Role::Owner; + member.active = true; + set_member(&env, &new_owner, &member); + } + + // Mark recovery as complete + let mut updated_state = emergency_state; + updated_state.recovery_approved = true; + updated_state.new_owner = new_owner.clone(); + set_emergency_state(&env, &updated_state); + + env.events().publish((RECOVERY_COMPLETED,), (new_owner,)); + } + + /// Cancel emergency recovery (original activator or any Owner) + pub fn cancel_emergency_recovery( + env: Env, + caller: Address, + ) { + caller.require_auth(); + + let emergency_state = get_emergency_state(&env).expect("Emergency not activated"); + if emergency_state.recovery_approved { + panic!("Recovery already executed"); + } + + let caller_member = get_member(&env, &caller).expect("Caller not a member"); + + // Only activator or any Owner can cancel + if caller != emergency_state.activated_by && caller_member.role != Role::Owner { + panic!("Unauthorized to cancel emergency"); + } + + // Remove emergency state + env.storage().persistent().remove(&DataKey::EmergencyState); + } + + // ==================== VIEW FUNCTIONS ==================== + + /// Get treasury configuration + pub fn get_config_info(env: Env) -> TreasuryConfig { + get_config(&env).expect("Not initialized") + } + + /// Get member information + pub fn get_member_info(env: Env, address: Address) -> Option { + get_member(&env, &address) + } + + /// Get all members + pub fn get_all_members(env: Env) -> Vec
{ + get_members(&env) + } + + /// Get transaction details + pub fn get_transaction_info(env: Env, tx_id: u64) -> Option { + get_transaction(&env, tx_id) + } + + /// Get pending transactions + pub fn get_pending_transaction_ids(env: Env) -> Vec { + get_pending_transactions(&env) + } + + /// Get transaction history + pub fn get_transaction_record(env: Env, tx_id: u64) -> Option { + get_transaction_history(&env, tx_id) + } + + /// Get total executed transaction count + pub fn get_executed_transaction_count(env: Env) -> u64 { + get_transaction_count(&env) + } + + /// Get emergency state + pub fn get_emergency_info(env: Env) -> Option { + get_emergency_state(&env) + } + + /// Check if an address has signed a transaction + pub fn has_signer_signed(env: Env, tx_id: u64, signer: Address) -> bool { + has_signed(&env, tx_id, &signer) + } + + // ==================== HELPER FUNCTIONS ==================== + + /// Create a new transaction proposal + fn create_transaction( + env: &Env, + proposer: Address, + tx_type: TransactionType, + target: Option
, + amount: Option, + destination: Option
, + function: Option, + args: Option>, + description: String, + required_role: Role, + ) -> u64 { + let config = get_config(env).expect("Not initialized"); + + // Check pending transaction limit + let pending = get_pending_transactions(env); + if pending.len() >= config.max_pending_proposals as u32 { + panic!("Too many pending proposals"); + } + + let tx_id = increment_transaction_id(env); + let current_time = env.ledger().timestamp(); + + let transaction = Transaction { + id: tx_id, + proposer, + transaction_type: tx_type, + status: TransactionStatus::Pending, + target, + amount, + destination, + function, + args, + signatures: Vec::new(env), + created_at: current_time, + expires_at: current_time + config.proposal_timeout, + executed_at: None, + description, + required_role, + }; + + set_transaction(env, &transaction); + + // Add to pending list + let mut pending = get_pending_transactions(env); + pending.push_back(tx_id); + set_pending_transactions(env, &pending); + + env.events().publish((TX_PROPOSED,), (tx_id, transaction.transaction_type.clone())); + + tx_id + } + + /// Verify address is an active signer + fn require_signer(env: &Env, address: &Address) { + let member = get_member(env, address).expect("Not a member"); + if !member.active { + panic!("Member not active"); + } + // Signer, Admin, and Owner can all sign + if member.role != Role::Signer && member.role != Role::Admin && member.role != Role::Owner { + panic!("Not a signer"); + } + } + + /// Get role level for comparison (Owner > Admin > Signer) + fn role_level(role: &Role) -> u8 { + match role { + Role::Signer => 1, + Role::Admin => 2, + Role::Owner => 3, + } + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/multisig_treasury/src/storage.rs b/contracts/multisig_treasury/src/storage.rs new file mode 100644 index 0000000..cc69c13 --- /dev/null +++ b/contracts/multisig_treasury/src/storage.rs @@ -0,0 +1,122 @@ +use soroban_sdk::{Address, Env, Vec}; +use crate::types::{DataKey, Member, Transaction, TransactionRecord, TreasuryConfig, EmergencyState}; + +/// Store treasury configuration +pub fn set_config(env: &Env, config: &TreasuryConfig) { + env.storage().instance().set(&DataKey::Config, config); +} + +/// Get treasury configuration +pub fn get_config(env: &Env) -> Option { + env.storage().instance().get(&DataKey::Config) +} + +/// Store member data +pub fn set_member(env: &Env, address: &Address, member: &Member) { + env.storage().persistent().set(&DataKey::Member(address.clone()), member); +} + +/// Get member data +pub fn get_member(env: &Env, address: &Address) -> Option { + env.storage().persistent().get(&DataKey::Member(address.clone())) +} + +/// Remove member data +pub fn remove_member(env: &Env, address: &Address) { + env.storage().persistent().remove(&DataKey::Member(address.clone())); +} + +/// Store the list of all members +pub fn set_members(env: &Env, members: &Vec
) { + env.storage().persistent().set(&DataKey::Members, members); +} + +/// Get the list of all members +pub fn get_members(env: &Env) -> Vec
{ + env.storage().persistent().get(&DataKey::Members).unwrap_or_else(|| Vec::new(env)) +} + +/// Store a transaction +pub fn set_transaction(env: &Env, transaction: &Transaction) { + env.storage().persistent().set(&DataKey::Transaction(transaction.id), transaction); +} + +/// Get a transaction by ID +pub fn get_transaction(env: &Env, id: u64) -> Option { + env.storage().persistent().get(&DataKey::Transaction(id)) +} + +/// Get the next transaction ID and increment counter +pub fn increment_transaction_id(env: &Env) -> u64 { + let key = DataKey::NextTransactionId; + let current: u64 = env.storage().instance().get(&key).unwrap_or(0); + let next = current + 1; + env.storage().instance().set(&key, &next); + next +} + +/// Get current transaction ID without incrementing +pub fn get_current_transaction_id(env: &Env) -> u64 { + env.storage().instance().get(&DataKey::NextTransactionId).unwrap_or(0) +} + +/// Check if an address has signed a transaction +pub fn has_signed(env: &Env, tx_id: u64, signer: &Address) -> bool { + env.storage().persistent().has(&DataKey::HasSigned(tx_id, signer.clone())) +} + +/// Mark that an address has signed a transaction +pub fn set_signed(env: &Env, tx_id: u64, signer: &Address) { + env.storage().persistent().set(&DataKey::HasSigned(tx_id, signer.clone()), &true); +} + +/// Store transaction history record +pub fn set_transaction_history(env: &Env, id: u64, record: &TransactionRecord) { + env.storage().persistent().set(&DataKey::TransactionHistory(id), record); +} + +/// Get transaction history record +pub fn get_transaction_history(env: &Env, id: u64) -> Option { + env.storage().persistent().get(&DataKey::TransactionHistory(id)) +} + +/// Get total transaction count +pub fn get_transaction_count(env: &Env) -> u64 { + env.storage().persistent().get(&DataKey::TransactionCount).unwrap_or(0) +} + +/// Increment transaction count +pub fn increment_transaction_count(env: &Env) { + let current = get_transaction_count(env); + env.storage().persistent().set(&DataKey::TransactionCount, &(current + 1)); +} + +/// Set emergency state +pub fn set_emergency_state(env: &Env, state: &EmergencyState) { + env.storage().persistent().set(&DataKey::EmergencyState, state); +} + +/// Get emergency state +pub fn get_emergency_state(env: &Env) -> Option { + env.storage().persistent().get(&DataKey::EmergencyState) +} + +/// Set last emergency timestamp +pub fn set_last_emergency_at(env: &Env, timestamp: u64) { + env.storage().persistent().set(&DataKey::LastEmergencyAt, ×tamp); +} + +/// Get last emergency timestamp +pub fn get_last_emergency_at(env: &Env) -> u64 { + env.storage().persistent().get(&DataKey::LastEmergencyAt).unwrap_or(0) +} + +/// Store pending transaction IDs +pub fn set_pending_transactions(env: &Env, ids: &Vec) { + env.storage().persistent().set(&DataKey::PendingTransactions, ids); +} + +/// Get pending transaction IDs +pub fn get_pending_transactions(env: &Env) -> Vec { + env.storage().persistent().get(&DataKey::PendingTransactions).unwrap_or_else(|| Vec::new(env)) +} diff --git a/contracts/multisig_treasury/src/test.rs b/contracts/multisig_treasury/src/test.rs new file mode 100644 index 0000000..22e97a7 --- /dev/null +++ b/contracts/multisig_treasury/src/test.rs @@ -0,0 +1,571 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{Address, Env, String, Symbol, IntoVal, Vec}; + +fn setup_env() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env +} + +#[test] +fn test_initialization() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let config = client.get_config_info(); + assert_eq!(config.owner, owner); + assert_eq!(config.threshold, 2); + assert_eq!(config.total_signers, 1); + assert_eq!(config.proposal_timeout, 86400); + assert_eq!(config.max_pending_proposals, 10); + assert!(config.emergency_recovery_enabled); + + let member = client.get_member_info(&owner).unwrap(); + assert!(matches!(member.role, Role::Owner)); + assert!(member.active); + + let members = client.get_all_members(); + assert_eq!(members.len(), 1); + assert_eq!(members.get(0).unwrap(), owner); +} + +#[test] +#[should_panic(expected = "Already initialized")] +fn test_double_initialization() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + client.initialize(&owner, &2, &86400, &10, &0); +} + +#[test] +#[should_panic(expected = "Invalid threshold")] +fn test_initialize_with_zero_threshold() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &0, &86400, &10, &0); +} + +#[test] +fn test_add_member() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let signer1 = Address::generate(&env); + let signer2 = Address::generate(&env); + let admin1 = Address::generate(&env); + + client.add_member(&owner, &signer1, &Role::Signer); + client.add_member(&owner, &signer2, &Role::Signer); + client.add_member(&owner, &admin1, &Role::Admin); + + let config = client.get_config_info(); + assert_eq!(config.total_signers, 4); + + let members = client.get_all_members(); + assert_eq!(members.len(), 4); +} + +#[test] +#[should_panic(expected = "Member already exists")] +fn test_add_duplicate_member() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let signer = Address::generate(&env); + client.add_member(&owner, &signer, &Role::Signer); + client.add_member(&owner, &signer, &Role::Signer); +} + +#[test] +#[should_panic(expected = "Insufficient role to add members")] +fn test_signer_cannot_add_member() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let signer = Address::generate(&env); + client.add_member(&owner, &signer, &Role::Signer); + + let new_member = Address::generate(&env); + client.add_member(&signer, &new_member, &Role::Signer); +} + +#[test] +#[should_panic(expected = "Only Owner can add Owners or Admins")] +fn test_admin_cannot_add_admin() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let admin = Address::generate(&env); + client.add_member(&owner, &admin, &Role::Admin); + + let new_admin = Address::generate(&env); + client.add_member(&admin, &new_admin, &Role::Admin); +} + +#[test] +fn test_remove_member() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let signer1 = Address::generate(&env); + let signer2 = Address::generate(&env); + + client.add_member(&owner, &signer1, &Role::Signer); + client.add_member(&owner, &signer2, &Role::Signer); + + client.remove_member(&owner, &signer1); + + assert!(client.get_member_info(&signer1).is_none()); + assert_eq!(client.get_all_members().len(), 2); + assert_eq!(client.get_config_info().total_signers, 2); +} + +#[test] +#[should_panic(expected = "Cannot remove last owner")] +fn test_cannot_remove_last_owner() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + client.remove_member(&owner, &owner); +} + +#[test] +fn test_admin_can_remove_signer() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let admin = Address::generate(&env); + let signer = Address::generate(&env); + + client.add_member(&owner, &admin, &Role::Admin); + client.add_member(&owner, &signer, &Role::Signer); + + client.remove_member(&admin, &signer); + assert!(client.get_member_info(&signer).is_none()); +} + +#[test] +#[should_panic(expected = "Admin can only remove Signers")] +fn test_admin_cannot_remove_admin() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + client.add_member(&owner, &admin1, &Role::Admin); + client.add_member(&owner, &admin2, &Role::Admin); + + client.remove_member(&admin1, &admin2); +} + +#[test] +fn test_update_member_role() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let signer = Address::generate(&env); + client.add_member(&owner, &signer, &Role::Signer); + client.update_member_role(&owner, &signer, &Role::Admin); + + assert!(matches!(client.get_member_info(&signer).unwrap().role, Role::Admin)); +} + +#[test] +fn test_propose_and_sign_transfer() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let signer1 = Address::generate(&env); + let signer2 = Address::generate(&env); + let token = Address::generate(&env); + let destination = Address::generate(&env); + + client.add_member(&owner, &signer1, &Role::Signer); + client.add_member(&owner, &signer2, &Role::Signer); + + let tx_id = client.propose_transfer(&owner, &token, &destination, &1000, &String::from_str(&env, "Test transfer")); + assert_eq!(tx_id, 1); + + let tx = client.get_transaction_info(&tx_id).unwrap(); + assert!(matches!(tx.status, TransactionStatus::Pending)); + + client.sign_transaction(&owner, &tx_id); + let tx = client.get_transaction_info(&tx_id).unwrap(); + assert_eq!(tx.signatures.len(), 1); + + client.sign_transaction(&signer1, &tx_id); + let tx = client.get_transaction_info(&tx_id).unwrap(); + assert_eq!(tx.signatures.len(), 2); + assert!(matches!(tx.status, TransactionStatus::Approved)); + + assert!(client.has_signer_signed(&tx_id, &owner)); + assert!(client.has_signer_signed(&tx_id, &signer1)); +} + +#[test] +#[should_panic(expected = "Already signed")] +fn test_cannot_sign_twice() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let token = Address::generate(&env); + let destination = Address::generate(&env); + + let tx_id = client.propose_transfer(&owner, &token, &destination, &1000, &String::from_str(&env, "Test")); + client.sign_transaction(&owner, &tx_id); + client.sign_transaction(&owner, &tx_id); +} + +#[test] +#[should_panic(expected = "Not a member")] +fn test_non_member_cannot_sign() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let token = Address::generate(&env); + let destination = Address::generate(&env); + let non_member = Address::generate(&env); + + let tx_id = client.propose_transfer(&owner, &token, &destination, &1000, &String::from_str(&env, "Test")); + client.sign_transaction(&non_member, &tx_id); +} + +#[test] +fn test_propose_contract_call() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let target = Address::generate(&env); + let args = Vec::from_array(&env, [100i32.into_val(&env)]); + + let tx_id = client.propose_contract_call(&owner, &target, &Symbol::new(&env, "test"), &args, &String::from_str(&env, "Test")); + let tx = client.get_transaction_info(&tx_id).unwrap(); + assert!(matches!(tx.transaction_type, TransactionType::ContractCall)); +} + +#[test] +fn test_reject_transaction() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let token = Address::generate(&env); + let destination = Address::generate(&env); + + let tx_id = client.propose_transfer(&owner, &token, &destination, &1000, &String::from_str(&env, "Test")); + client.reject_transaction(&owner, &tx_id); + + let tx = client.get_transaction_info(&tx_id).unwrap(); + assert!(matches!(tx.status, TransactionStatus::Rejected)); + assert_eq!(client.get_pending_transaction_ids().len(), 0); +} + +#[test] +#[should_panic(expected = "Only proposer can reject")] +fn test_non_proposer_cannot_reject() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let signer = Address::generate(&env); + client.add_member(&owner, &signer, &Role::Signer); + + let token = Address::generate(&env); + let destination = Address::generate(&env); + + let tx_id = client.propose_transfer(&owner, &token, &destination, &1000, &String::from_str(&env, "Test")); + client.reject_transaction(&signer, &tx_id); +} + +#[test] +fn test_update_config() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let signer1 = Address::generate(&env); + let signer2 = Address::generate(&env); + client.add_member(&owner, &signer1, &Role::Signer); + client.add_member(&owner, &signer2, &Role::Signer); + + client.update_config(&owner, &3, &172800, &20); + + let config = client.get_config_info(); + assert_eq!(config.threshold, 3); + assert_eq!(config.proposal_timeout, 172800); + assert_eq!(config.max_pending_proposals, 20); +} + +#[test] +#[should_panic(expected = "Only Owner can update config")] +fn test_non_owner_cannot_update_config() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let signer = Address::generate(&env); + client.add_member(&owner, &signer, &Role::Signer); + + client.update_config(&signer, &1, &86400, &10); +} + +#[test] +fn test_emergency_recovery_activation() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let owner2 = Address::generate(&env); + client.add_member(&owner, &owner2, &Role::Owner); + + client.activate_emergency_recovery(&owner, &String::from_str(&env, "Security breach")); + + let emergency = client.get_emergency_info().unwrap(); + assert!(!emergency.recovery_approved); +} + +#[test] +#[should_panic(expected = "Only Owner can activate emergency recovery")] +fn test_non_owner_cannot_activate_emergency() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let admin = Address::generate(&env); + client.add_member(&owner, &admin, &Role::Admin); + + client.activate_emergency_recovery(&admin, &String::from_str(&env, "Emergency")); +} + +#[test] +fn test_emergency_recovery_execution() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let owner2 = Address::generate(&env); + client.add_member(&owner, &owner2, &Role::Owner); + + let new_owner = Address::generate(&env); + + client.activate_emergency_recovery(&owner, &String::from_str(&env, "Compromised")); + client.execute_emergency_recovery(&owner2, &new_owner); + + assert_eq!(client.get_config_info().owner, new_owner); + assert!(matches!(client.get_member_info(&new_owner).unwrap().role, Role::Owner)); + assert!(client.get_emergency_info().unwrap().recovery_approved); +} + +#[test] +fn test_cancel_emergency_recovery() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let owner2 = Address::generate(&env); + client.add_member(&owner, &owner2, &Role::Owner); + + client.activate_emergency_recovery(&owner, &String::from_str(&env, "False alarm")); + client.cancel_emergency_recovery(&owner2); + + assert!(client.get_emergency_info().is_none()); +} + +#[test] +#[should_panic(expected = "Activator cannot be the only approver")] +fn test_emergency_requires_second_owner() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let owner2 = Address::generate(&env); + client.add_member(&owner, &owner2, &Role::Owner); + + let new_owner = Address::generate(&env); + + client.activate_emergency_recovery(&owner, &String::from_str(&env, "Emergency")); + client.execute_emergency_recovery(&owner, &new_owner); +} + +#[test] +fn test_transaction_counter() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let token = Address::generate(&env); + let destination = Address::generate(&env); + + let tx1 = client.propose_transfer(&owner, &token, &destination, &100, &String::from_str(&env, "First")); + let tx2 = client.propose_transfer(&owner, &token, &destination, &200, &String::from_str(&env, "Second")); + let tx3 = client.propose_transfer(&owner, &token, &destination, &300, &String::from_str(&env, "Third")); + + assert_eq!(tx1, 1); + assert_eq!(tx2, 2); + assert_eq!(tx3, 3); + assert_eq!(client.get_pending_transaction_ids().len(), 3); +} + +#[test] +fn test_role_levels() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &2, &86400, &10, &0); + + let admin = Address::generate(&env); + let signer = Address::generate(&env); + + client.add_member(&owner, &admin, &Role::Admin); + client.add_member(&owner, &signer, &Role::Signer); + + assert!(matches!(client.get_member_info(&owner).unwrap().role, Role::Owner)); + assert!(matches!(client.get_member_info(&admin).unwrap().role, Role::Admin)); + assert!(matches!(client.get_member_info(&signer).unwrap().role, Role::Signer)); +} + +#[test] +#[should_panic(expected = "Too many pending proposals")] +fn test_pending_transaction_limit() { + let env = setup_env(); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, MultisigTreasury); + let client = MultisigTreasuryClient::new(&env, &contract_id); + + client.initialize(&owner, &1, &86400, &3, &0); + + let token = Address::generate(&env); + let destination = Address::generate(&env); + + client.propose_transfer(&owner, &token, &destination, &100, &String::from_str(&env, "1")); + client.propose_transfer(&owner, &token, &destination, &200, &String::from_str(&env, "2")); + client.propose_transfer(&owner, &token, &destination, &300, &String::from_str(&env, "3")); + + client.propose_transfer(&owner, &token, &destination, &400, &String::from_str(&env, "4")); +} diff --git a/contracts/multisig_treasury/src/types.rs b/contracts/multisig_treasury/src/types.rs new file mode 100644 index 0000000..21a66c0 --- /dev/null +++ b/contracts/multisig_treasury/src/types.rs @@ -0,0 +1,208 @@ +use soroban_sdk::{contracttype, Address, String, Symbol, Val, Vec}; + +/// Role-based access levels for treasury members +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Role { + /// Can propose and sign transactions + Signer, + /// Can propose, sign, and manage signers + Admin, + /// Full control including emergency recovery + Owner, +} + +/// Status of a transaction proposal +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TransactionStatus { + /// Proposal created, awaiting signatures + Pending, + /// Enough signatures collected, ready to execute + Approved, + /// Transaction has been executed + Executed, + /// Transaction was rejected or canceled + Rejected, + /// Transaction expired before reaching threshold + Expired, +} + +/// Type of transaction that can be proposed +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TransactionType { + /// Transfer tokens to an address + TokenTransfer, + /// Transfer native asset (XLM) + NativeTransfer, + /// Call another contract + ContractCall, + /// Update treasury configuration + ConfigUpdate, + /// Add or remove a signer + SignerManagement, + /// Emergency action + EmergencyAction, +} + +/// Configuration for the treasury +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TreasuryConfig { + /// Contract address that owns this treasury + pub owner: Address, + /// Minimum number of signatures required (M in M-of-N) + pub threshold: u32, + /// Total number of signers (N in M-of-N) + pub total_signers: u32, + /// Time limit for proposals to collect signatures (in seconds) + pub proposal_timeout: u64, + /// Maximum number of pending proposals allowed + pub max_pending_proposals: u32, + /// Whether emergency recovery is enabled + pub emergency_recovery_enabled: bool, + /// Cooldown period after emergency recovery (in seconds) + pub emergency_cooldown: u64, +} + +/// Member of the treasury with a role +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Member { + pub address: Address, + pub role: Role, + pub added_at: u64, + pub active: bool, +} + +/// A signature on a transaction proposal +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Signature { + pub signer: Address, + pub timestamp: u64, +} + +/// Transaction proposal data +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Transaction { + pub id: u64, + pub proposer: Address, + pub transaction_type: TransactionType, + pub status: TransactionStatus, + /// Target token contract for transfers, or target contract for calls + pub target: Option
, + /// Amount for transfers + pub amount: Option, + /// Destination address for transfers + pub destination: Option
, + /// Function to call on target contract + pub function: Option, + /// Arguments for contract calls + pub args: Option>, + /// Signatures collected so far + pub signatures: Vec, + pub created_at: u64, + pub expires_at: u64, + pub executed_at: Option, + /// Description of the transaction + pub description: String, + /// Minimum role required to sign this transaction + pub required_role: Role, +} + +/// Record of a completed transaction for history +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionRecord { + pub id: u64, + pub transaction_type: TransactionType, + pub status: TransactionStatus, + pub proposer: Address, + pub signers: Vec
, + pub executed_at: u64, +} + +/// Emergency recovery state +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EmergencyState { + pub activated_at: u64, + pub activated_by: Address, + pub reason: String, + pub new_owner: Address, + pub recovery_approved: bool, +} + +/// Data keys for storage +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + /// Treasury configuration + Config, + /// Member data for an address + Member(Address), + /// List of all member addresses + Members, + /// Transaction by ID + Transaction(u64), + /// Next transaction ID counter + NextTransactionId, + /// Whether an address has signed a transaction + HasSigned(u64, Address), + /// Transaction history record + TransactionHistory(u64), + /// Total number of transactions executed + TransactionCount, + /// Emergency recovery state + EmergencyState, + /// Last emergency timestamp + LastEmergencyAt, + /// Pending transaction IDs + PendingTransactions, +} + +/// Errors that can occur in the contract +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TreasuryError { + /// Contract not initialized + NotInitialized = 1, + /// Already initialized + AlreadyInitialized = 2, + /// Unauthorized access + Unauthorized = 3, + /// Member not found + MemberNotFound = 4, + /// Member already exists + MemberAlreadyExists = 5, + /// Transaction not found + TransactionNotFound = 6, + /// Transaction already executed + AlreadyExecuted = 7, + /// Already signed by this member + AlreadySigned = 8, + /// Transaction expired + TransactionExpired = 9, + /// Threshold not reached + ThresholdNotReached = 10, + /// Too many pending proposals + TooManyPendingProposals = 11, + /// Invalid threshold (M > N) + InvalidThreshold = 12, + /// Cannot remove last owner + CannotRemoveLastOwner = 13, + /// Emergency recovery not available + EmergencyNotAvailable = 14, + /// Emergency cooldown active + EmergencyCooldownActive = 15, + /// Invalid transaction parameters + InvalidParameters = 16, + /// Insufficient role level + InsufficientRole = 17, + /// Transfer failed + TransferFailed = 18, + /// Transaction not approved + NotApproved = 19, +}