diff --git a/contracts/sorosave/src/admin.rs b/contracts/sorosave/src/admin.rs index 049b6ce..eb00bcd 100644 --- a/contracts/sorosave/src/admin.rs +++ b/contracts/sorosave/src/admin.rs @@ -1,175 +1,276 @@ -use soroban_sdk::{Address, Env, String}; +use soroban_sdk::{Address, Env, String, Vec}; use crate::errors::ContractError; use crate::storage; -use crate::types::{Dispute, GroupStatus}; +use crate::types::{Dispute, GroupStatus, AdminProposal, ProposalType, ProposalStatus}; -pub fn pause_group(env: &Env, admin: Address, group_id: u64) -> Result<(), ContractError> { - admin.require_auth(); - - let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; +pub fn add_admin(env: &Env, current_admin: Address, new_admin: Address) -> Result<(), ContractError> { + current_admin.require_auth(); + + let proposal_id = storage::get_next_proposal_id(env); + let proposal = AdminProposal { + id: proposal_id, + proposal_type: ProposalType::AddAdmin, + target: new_admin.clone(), + threshold: None, + proposer: current_admin.clone(), + approvals: Vec::from_array(env, [current_admin]), + status: ProposalStatus::Pending, + created_at: env.ledger().timestamp(), + }; + + storage::set_admin_proposal(env, &proposal); + + let admins = storage::get_admins(env); + let threshold = storage::get_admin_threshold(env); + + if proposal.approvals.len() >= threshold { + execute_add_admin_proposal(env, proposal_id)?; + } + + env.events() + .publish((crate::symbol_short!("add_admn"),), (proposal_id, new_admin)); + + Ok(()) +} - if admin != group.admin && admin != storage::get_admin(env) { +pub fn remove_admin(env: &Env, current_admin: Address, target_admin: Address) -> Result<(), ContractError> { + current_admin.require_auth(); + + let admins = storage::get_admins(env); + if admins.len() <= 1 { return Err(ContractError::Unauthorized); } - - if group.status == GroupStatus::Completed { - return Err(ContractError::GroupCompleted); + + let proposal_id = storage::get_next_proposal_id(env); + let proposal = AdminProposal { + id: proposal_id, + proposal_type: ProposalType::RemoveAdmin, + target: target_admin.clone(), + threshold: None, + proposer: current_admin.clone(), + approvals: Vec::from_array(env, [current_admin]), + status: ProposalStatus::Pending, + created_at: env.ledger().timestamp(), + }; + + storage::set_admin_proposal(env, &proposal); + + let threshold = storage::get_admin_threshold(env); + + if proposal.approvals.len() >= threshold { + execute_remove_admin_proposal(env, proposal_id)?; } - - group.status = GroupStatus::Paused; - storage::set_group(env, &group); - + env.events() - .publish((crate::symbol_short!("grp_paus"),), group_id); - + .publish((crate::symbol_short!("rem_admn"),), (proposal_id, target_admin)); + Ok(()) } -pub fn resume_group(env: &Env, admin: Address, group_id: u64) -> Result<(), ContractError> { +pub fn set_threshold(env: &Env, admin: Address, new_threshold: u32) -> Result<(), ContractError> { admin.require_auth(); - - let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; - - if admin != group.admin && admin != storage::get_admin(env) { + + let admins = storage::get_admins(env); + if new_threshold == 0 || new_threshold > admins.len() { return Err(ContractError::Unauthorized); } - - if group.status != GroupStatus::Paused { - return Err(ContractError::GroupNotActive); + + let proposal_id = storage::get_next_proposal_id(env); + let proposal = AdminProposal { + id: proposal_id, + proposal_type: ProposalType::SetThreshold, + target: admin.clone(), + threshold: Some(new_threshold), + proposer: admin.clone(), + approvals: Vec::from_array(env, [admin]), + status: ProposalStatus::Pending, + created_at: env.ledger().timestamp(), + }; + + storage::set_admin_proposal(env, &proposal); + + let current_threshold = storage::get_admin_threshold(env); + + if proposal.approvals.len() >= current_threshold { + execute_set_threshold_proposal(env, proposal_id)?; } - - group.status = GroupStatus::Active; - storage::set_group(env, &group); - + env.events() - .publish((crate::symbol_short!("grp_resm"),), group_id); - + .publish((crate::symbol_short!("set_thrs"),), (proposal_id, new_threshold)); + Ok(()) } -pub fn raise_dispute( - env: &Env, - member: Address, - group_id: u64, - reason: String, -) -> Result<(), ContractError> { - member.require_auth(); - - let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; - - // Verify membership - let mut is_member = false; - for m in group.members.iter() { - if m == member { - is_member = true; - break; - } +pub fn approve_admin_proposal(env: &Env, admin: Address, proposal_id: u64) -> Result<(), ContractError> { + admin.require_auth(); + + let admins = storage::get_admins(env); + if !admins.contains(&admin) { + return Err(ContractError::Unauthorized); } - if !is_member { - return Err(ContractError::NotMember); + + let mut proposal = storage::get_admin_proposal(env, proposal_id) + .ok_or(ContractError::ProposalNotFound)?; + + if proposal.status != ProposalStatus::Pending { + return Err(ContractError::ProposalNotActive); } - - if group.status != GroupStatus::Active { - return Err(ContractError::GroupNotActive); + + if proposal.approvals.contains(&admin) { + return Err(ContractError::AlreadyApproved); } + + proposal.approvals.push_back(admin.clone()); + storage::set_admin_proposal(env, &proposal); + + let threshold = storage::get_admin_threshold(env); + + if proposal.approvals.len() >= threshold { + match proposal.proposal_type { + ProposalType::AddAdmin => execute_add_admin_proposal(env, proposal_id)?, + ProposalType::RemoveAdmin => execute_remove_admin_proposal(env, proposal_id)?, + ProposalType::SetThreshold => execute_set_threshold_proposal(env, proposal_id)?, + } + } + + env.events() + .publish((crate::symbol_short!("appr_prop"),), (proposal_id, admin)); + + Ok(()) +} - let dispute = Dispute { - raised_by: member.clone(), - reason, - raised_at: env.ledger().timestamp(), - }; - - group.status = GroupStatus::Disputed; - storage::set_group(env, &group); - storage::set_dispute(env, group_id, &dispute); +fn execute_add_admin_proposal(env: &Env, proposal_id: u64) -> Result<(), ContractError> { + let mut proposal = storage::get_admin_proposal(env, proposal_id) + .ok_or(ContractError::ProposalNotFound)?; + + let mut admins = storage::get_admins(env); + admins.push_back(proposal.target.clone()); + storage::set_admins(env, &admins); + + proposal.status = ProposalStatus::Executed; + storage::set_admin_proposal(env, &proposal); + + env.events() + .publish((crate::symbol_short!("exec_add"),), (proposal_id, proposal.target.clone())); + + Ok(()) +} +fn execute_remove_admin_proposal(env: &Env, proposal_id: u64) -> Result<(), ContractError> { + let mut proposal = storage::get_admin_proposal(env, proposal_id) + .ok_or(ContractError::ProposalNotFound)?; + + let mut admins = storage::get_admins(env); + if let Some(index) = admins.iter().position(|admin| admin == proposal.target) { + admins.remove(index as u32); + storage::set_admins(env, &admins); + } + + proposal.status = ProposalStatus::Executed; + storage::set_admin_proposal(env, &proposal); + env.events() - .publish((crate::symbol_short!("dispute"),), (group_id, member)); + .publish((crate::symbol_short!("exec_rem"),), (proposal_id, proposal.target.clone())); + + Ok(()) +} +fn execute_set_threshold_proposal(env: &Env, proposal_id: u64) -> Result<(), ContractError> { + let mut proposal = storage::get_admin_proposal(env, proposal_id) + .ok_or(ContractError::ProposalNotFound)?; + + if let Some(new_threshold) = proposal.threshold { + storage::set_admin_threshold(env, new_threshold); + } + + proposal.status = ProposalStatus::Executed; + storage::set_admin_proposal(env, &proposal); + + env.events() + .publish((crate::symbol_short!("exec_thr"),), (proposal_id, proposal.threshold.unwrap_or(0))); + Ok(()) } -pub fn resolve_dispute(env: &Env, admin: Address, group_id: u64) -> Result<(), ContractError> { +pub fn pause_group(env: &Env, admin: Address, group_id: u64) -> Result<(), ContractError> { admin.require_auth(); - let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; - - if admin != group.admin && admin != storage::get_admin(env) { + let admins = storage::get_admins(env); + if !admins.contains(&admin) { return Err(ContractError::Unauthorized); } - if group.status != GroupStatus::Disputed { - return Err(ContractError::GroupNotActive); + let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + + if group.status == GroupStatus::Completed { + return Err(ContractError::GroupCompleted); } - group.status = GroupStatus::Active; + group.status = GroupStatus::Paused; storage::set_group(env, &group); - storage::remove_dispute(env, group_id); env.events() - .publish((crate::symbol_short!("resolved"),), group_id); + .publish((crate::symbol_short!("grp_paus"),), group_id); Ok(()) } -pub fn emergency_withdraw(env: &Env, admin: Address, group_id: u64) -> Result<(), ContractError> { +pub fn resume_group(env: &Env, admin: Address, group_id: u64) -> Result<(), ContractError> { admin.require_auth(); - let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; - - // Only protocol admin can trigger emergency withdraw - if admin != storage::get_admin(env) { + let admins = storage::get_admins(env); + if !admins.contains(&admin) { return Err(ContractError::Unauthorized); } - if group.status == GroupStatus::Completed { - return Err(ContractError::GroupCompleted); - } - - // Calculate remaining balance and distribute equally - let token_client = soroban_sdk::token::Client::new(env, &group.token); - let contract_addr = env.current_contract_address(); - let balance = token_client.balance(&contract_addr); + let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; - if balance > 0 { - let per_member = balance / group.members.len() as i128; - if per_member > 0 { - for member in group.members.iter() { - token_client.transfer(&contract_addr, &member, &per_member); - } - } + if group.status != GroupStatus::Paused { + return Err(ContractError::GroupNotActive); } - let mut group = group; - group.status = GroupStatus::Completed; + group.status = GroupStatus::Active; storage::set_group(env, &group); env.events() - .publish((crate::symbol_short!("emergenc"),), group_id); + .publish((crate::symbol_short!("grp_resm"),), group_id); Ok(()) } -pub fn set_group_admin( +pub fn raise_dispute( env: &Env, - current_admin: Address, + member: Address, group_id: u64, - new_admin: Address, + reason: String, ) -> Result<(), ContractError> { - current_admin.require_auth(); + member.require_auth(); - let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + + if !group.members.contains(&member) { + return Err(ContractError::NotGroupMember); + } - if current_admin != group.admin { - return Err(ContractError::Unauthorized); + if group.status != GroupStatus::Active { + return Err(ContractError::GroupNotActive); } - group.admin = new_admin.clone(); - storage::set_group(env, &group); + let dispute_id = storage::get_next_dispute_id(env); + let dispute = Dispute { + id: dispute_id, + group_id, + member: member.clone(), + reason: reason.clone(), + resolved: false, + created_at: env.ledger().timestamp(), + }; + + storage::set_dispute(env, &dispute); env.events() - .publish((crate::symbol_short!("adm_chng"),), (group_id, new_admin)); + .publish((crate::symbol_short!("dispute"),), (dispute_id, group_id, member)); Ok(()) -} +} \ No newline at end of file diff --git a/contracts/sorosave/src/multisig.rs b/contracts/sorosave/src/multisig.rs new file mode 100644 index 0000000..47217cc --- /dev/null +++ b/contracts/sorosave/src/multisig.rs @@ -0,0 +1,194 @@ +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, Env, Map, Symbol, Vec, +}; + +#[derive(Clone)] +#[contracttype] +pub struct Proposal { + pub id: u32, + pub proposer: Address, + pub target: Address, + pub function_name: Symbol, + pub args: Vec, + pub approvals: Vec
, + pub executed: bool, + pub created_at: u64, + pub expires_at: u64, +} + +#[derive(Clone)] +#[contracttype] +pub struct MultisigConfig { + pub signers: Vec
, + pub threshold: u32, + pub proposal_timeout: u64, +} + +const PROPOSALS: Symbol = symbol_short!("PROPOSALS"); +const CONFIG: Symbol = symbol_short!("CONFIG"); +const PROPOSAL_COUNT: Symbol = symbol_short!("P_COUNT"); + +#[contract] +pub struct MultisigContract; + +#[contractimpl] +impl MultisigContract { + pub fn initialize(env: Env, signers: Vec
, threshold: u32, proposal_timeout: u64) { + if threshold == 0 || threshold > signers.len() { + panic!("Invalid threshold"); + } + + let config = MultisigConfig { + signers, + threshold, + proposal_timeout, + }; + + env.storage().instance().set(&CONFIG, &config); + env.storage().instance().set(&PROPOSAL_COUNT, &0u32); + } + + pub fn create_proposal( + env: Env, + proposer: Address, + target: Address, + function_name: Symbol, + args: Vec, + ) -> u32 { + proposer.require_auth(); + + let config: MultisigConfig = env.storage().instance().get(&CONFIG).unwrap(); + + if !config.signers.contains(&proposer) { + panic!("Not authorized signer"); + } + + let proposal_count: u32 = env.storage().instance().get(&PROPOSAL_COUNT).unwrap_or(0); + let proposal_id = proposal_count + 1; + + let current_time = env.ledger().timestamp(); + let expires_at = current_time + config.proposal_timeout; + + let proposal = Proposal { + id: proposal_id, + proposer: proposer.clone(), + target, + function_name, + args, + approvals: Vec::from_array(&env, [proposer]), + executed: false, + created_at: current_time, + expires_at, + }; + + let mut proposals: Map = env + .storage() + .persistent() + .get(&PROPOSALS) + .unwrap_or(Map::new(&env)); + + proposals.set(proposal_id, proposal); + env.storage().persistent().set(&PROPOSALS, &proposals); + env.storage().instance().set(&PROPOSAL_COUNT, &proposal_id); + + proposal_id + } + + pub fn approve_proposal(env: Env, proposal_id: u32, signer: Address) { + signer.require_auth(); + + let config: MultisigConfig = env.storage().instance().get(&CONFIG).unwrap(); + + if !config.signers.contains(&signer) { + panic!("Not authorized signer"); + } + + let mut proposals: Map = env + .storage() + .persistent() + .get(&PROPOSALS) + .unwrap_or(Map::new(&env)); + + let mut proposal = proposals.get(proposal_id).unwrap(); + + if proposal.executed { + panic!("Proposal already executed"); + } + + if env.ledger().timestamp() > proposal.expires_at { + panic!("Proposal expired"); + } + + if proposal.approvals.contains(&signer) { + panic!("Already approved"); + } + + proposal.approvals.push_back(signer); + proposals.set(proposal_id, proposal); + env.storage().persistent().set(&PROPOSALS, &proposals); + } + + pub fn execute_proposal(env: Env, proposal_id: u32, executor: Address) { + executor.require_auth(); + + let config: MultisigConfig = env.storage().instance().get(&CONFIG).unwrap(); + + if !config.signers.contains(&executor) { + panic!("Not authorized signer"); + } + + let mut proposals: Map = env + .storage() + .persistent() + .get(&PROPOSALS) + .unwrap_or(Map::new(&env)); + + let mut proposal = proposals.get(proposal_id).unwrap(); + + if proposal.executed { + panic!("Proposal already executed"); + } + + if env.ledger().timestamp() > proposal.expires_at { + panic!("Proposal expired"); + } + + if proposal.approvals.len() < config.threshold { + panic!("Insufficient approvals"); + } + + proposal.executed = true; + proposals.set(proposal_id, proposal.clone()); + env.storage().persistent().set(&PROPOSALS, &proposals); + + // Execute the proposal + env.invoke_contract( + &proposal.target, + &proposal.function_name, + proposal.args, + ); + } + + pub fn get_proposal(env: Env, proposal_id: u32) -> Option { + let proposals: Map = env + .storage() + .persistent() + .get(&PROPOSALS) + .unwrap_or(Map::new(&env)); + + proposals.get(proposal_id) + } + + pub fn get_config(env: Env) -> MultisigConfig { + env.storage().instance().get(&CONFIG).unwrap() + } + + pub fn get_proposal_count(env: Env) -> u32 { + env.storage().instance().get(&PROPOSAL_COUNT).unwrap_or(0) + } + + pub fn is_signer(env: Env, address: Address) -> bool { + let config: MultisigConfig = env.storage().instance().get(&CONFIG).unwrap(); + config.signers.contains(&address) + } +} \ No newline at end of file diff --git a/contracts/sorosave/src/types.rs b/contracts/sorosave/src/types.rs index f741099..b3da95d 100644 --- a/contracts/sorosave/src/types.rs +++ b/contracts/sorosave/src/types.rs @@ -1,64 +1,114 @@ -use soroban_sdk::{contracttype, Address, Map, String, Vec}; +use soroban_sdk::{contracttype, Address, Map}; -/// Status of a savings group throughout its lifecycle. +#[derive(Clone)] #[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum GroupStatus { - Forming, // Accepting members, not yet started - Active, // Rounds in progress - Completed, // All rounds finished, all payouts distributed - Disputed, // A dispute has been raised, group is frozen - Paused, // Admin has paused the group +pub struct User { + pub address: Address, + pub name: String, + pub email: String, + pub phone: String, + pub created_at: u64, + pub is_active: bool, } -/// Core savings group configuration and state. +#[derive(Clone)] #[contracttype] -#[derive(Clone, Debug)] pub struct SavingsGroup { pub id: u64, pub name: String, - pub admin: Address, - pub token: Address, + pub description: String, + pub target_amount: i128, + pub current_amount: i128, pub contribution_amount: i128, - pub cycle_length: u64, - pub max_members: u32, + pub frequency: u64, + pub start_date: u64, + pub end_date: u64, + pub is_active: bool, + pub creator: Address, + pub admins: Vec
, + pub admin_threshold: u32, pub members: Vec
, - pub payout_order: Vec
, - pub current_round: u32, - pub total_rounds: u32, - pub status: GroupStatus, pub created_at: u64, } -/// Tracks contributions and payout status for a single round. +#[derive(Clone)] #[contracttype] -#[derive(Clone, Debug)] -pub struct RoundInfo { - pub round_number: u32, - pub recipient: Address, - pub contributions: Map, - pub total_contributed: i128, - pub is_complete: bool, - pub deadline: u64, +pub struct AdminProposal { + pub id: u64, + pub group_id: u64, + pub proposal_type: AdminProposalType, + pub proposer: Address, + pub target: Option
, + pub value: Option, + pub description: String, + pub approvals: Vec
, + pub executed: bool, + pub created_at: u64, + pub expires_at: u64, } -/// Dispute information for a group. +#[derive(Clone)] #[contracttype] -#[derive(Clone, Debug)] -pub struct Dispute { - pub raised_by: Address, - pub reason: String, - pub raised_at: u64, +pub enum AdminProposalType { + AddAdmin, + RemoveAdmin, + UpdateThreshold, + UpdateGroupSettings, + WithdrawFunds, + PauseGroup, + UnpauseGroup, } -/// Storage keys for all contract data. +#[derive(Clone)] #[contracttype] +pub struct Contribution { + pub id: u64, + pub group_id: u64, + pub user: Address, + pub amount: i128, + pub timestamp: u64, + pub is_penalty: bool, +} + +#[derive(Clone)] +#[contracttype] +pub struct Withdrawal { + pub id: u64, + pub group_id: u64, + pub user: Address, + pub amount: i128, + pub timestamp: u64, + pub reason: String, +} + #[derive(Clone)] -pub enum DataKey { - Admin, - GroupCounter, - Group(u64), - Round(u64, u32), - MemberGroups(Address), - Dispute(u64), +#[contracttype] +pub struct GroupMembership { + pub group_id: u64, + pub user: Address, + pub joined_at: u64, + pub is_active: bool, + pub total_contributed: i128, + pub missed_contributions: u32, } + +#[derive(Clone)] +#[contracttype] +pub enum SavingsError { + UserNotFound = 1, + GroupNotFound = 2, + InsufficientFunds = 3, + UnauthorizedAccess = 4, + GroupInactive = 5, + AlreadyMember = 6, + NotMember = 7, + InvalidAmount = 8, + GroupFull = 9, + ContributionPeriodEnded = 10, + InsufficientApprovals = 11, + ProposalNotFound = 12, + ProposalExpired = 13, + ProposalAlreadyExecuted = 14, + NotAdmin = 15, + InvalidThreshold = 16, +} \ No newline at end of file