diff --git a/contracts/loan/src/lib.rs b/contracts/loan/src/lib.rs index e08ceae..4c578a6 100644 --- a/contracts/loan/src/lib.rs +++ b/contracts/loan/src/lib.rs @@ -172,6 +172,68 @@ impl LoanContract { ); } + /// Marks a loan as defaulted if it is past due and has pending balance. + /// + /// # Authorization + /// Anyone can call this function (community enforcement). + /// + /// # Arguments + /// * `loan_id` - The ID of the loan to mark as defaulted + /// + /// # Panics + /// - If loan does not exist + /// - If loan is not in `Approved` status + /// - If loan is not past due (`repayment_due` > current ledger timestamp) + /// - If loan has been fully repaid + /// + /// # Events + /// Emits `loan_defaulted` with loan_id, borrower, and pending_amount. + pub fn mark_defaulted(env: Env, loan_id: u32) { + // 1. Obtener todos los préstamos + let mut loans: Vec = env.storage().instance() + .get(&DataKey::Loans).unwrap_or_else(|| panic!("no loans found")); + + // 2. Encontrar el índice del préstamo + let idx = Self::find_loan_idx(&loans, loan_id); + let mut loan = loans.get(idx).unwrap(); + + // 3. Validar que el préstamo está en estado Approved + if loan.status != LoanStatus::Approved { + panic!("loan must be in Approved status"); + } + + // 4. Validar que el préstamo está vencido + let current_timestamp = env.ledger().timestamp(); + if current_timestamp <= loan.repayment_due { + panic!("loan is not past due"); + } + + // 5. Validar que hay saldo pendiente + let total_due = loan.amount + (loan.amount * loan.interest_bps as i128 / 10_000); + if loan.amount_repaid >= total_due { + panic!("loan has been fully repaid"); + } + + // 6. Calcular el monto pendiente + let pending_amount = total_due - loan.amount_repaid; + + // 7. Actualizar el estado a Defaulted + loan.status = LoanStatus::Defaulted; + + // 8. Guardar el préstamo actualizado + loans.set(idx, loan.clone()); + env.storage().instance().set(&DataKey::Loans, &loans); + + // 9. Emitir el evento + env.events().publish( + (Symbol::new(&env, "loan_defaulted"),), + (loan_id, loan.borrower, pending_amount), + ); + + // 10. Extender TTL del storage de instancia (100 ledgers) + env.storage().instance().extend_ttl(100, 100); + } + /// Get all loans. pub fn get_loans(env: Env) -> Vec { env.storage().instance() @@ -201,3 +263,189 @@ impl LoanContract { panic!("loan not found"); } } + +/// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::{token::Client as TokenClient, token::StellarAssetClient, Env}; + + fn setup() -> (Env, LoanContractClient<'static>, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, LoanContract); + let client = LoanContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let borrower = Address::generate(&env); + let token_admin = Address::generate(&env); + let asset = env.register_stellar_asset_contract_v2(token_admin.clone()); + let asset_address = asset.address(); + + // Fund borrower + StellarAssetClient::new(&env, &asset_address) + .mint(&borrower, &100_000_0000000i128); + + // Fund the contract itself (treasury) for disbursement + StellarAssetClient::new(&env, &asset_address) + .mint(&contract_id, &100_000_0000000i128); + + // Initialize contract + client.initialize(&admin, &admin, &asset_address); + + (env, client, admin, borrower, asset_address) + } + + fn create_approved_loan( + env: &Env, + client: &LoanContractClient<'static>, + admin: &Address, + borrower: &Address, + ) -> u32 { + // Request loan + let loan_id = client.request_loan( + borrower, + &10_000_0000000i128, + &String::from_str(env, "Test loan"), + &30, // 30 days + ); + + // Approve loan (disburses funds) + client.approve_loan(admin, &loan_id); + + loan_id + } + + #[test] + fn test_initialize() { + // setup() already initializes, so we just verify no panic + let (env, client, admin, _, asset) = setup(); + // Verify contract is initialized by checking loans exist + let loans = client.get_loans(); + assert_eq!(loans.len(), 0); + } + + #[test] + #[should_panic(expected = "already initialized")] + fn test_double_initialize() { + let (env, client, admin, _, asset) = setup(); + // Try to initialize again + client.initialize(&admin, &admin, &asset); + } + + #[test] + fn test_request_loan() { + let (env, client, _, borrower, _) = setup(); + let loan_id = client.request_loan( + &borrower, + &1_000_0000000i128, + &String::from_str(&env, "Test loan"), + &30, + ); + assert_eq!(loan_id, 1); + } + + #[test] + fn test_approve_loan() { + let (env, client, admin, borrower, _) = setup(); + let loan_id = client.request_loan( + &borrower, + &10_000_0000000i128, + &String::from_str(&env, "Test loan"), + &30, + ); + client.approve_loan(&admin, &loan_id); + let loan = client.get_loan(&loan_id); + assert_eq!(loan.status, LoanStatus::Approved); + } + + #[test] + fn test_repay_loan() { + let (env, client, admin, borrower, _) = setup(); + let loan_id = create_approved_loan(&env, &client, &admin, &borrower); + + // Calculate total due (5% interest) + let principal = 10_000_0000000i128; + let interest = principal * 500 / 10_000; + let total_due = principal + interest; + + // Repay loan + client.repay(&borrower, &loan_id, &total_due); + + let loan = client.get_loan(&loan_id); + assert_eq!(loan.status, LoanStatus::Repaid); + } + + // ── mark_defaulted tests ──────────────────────────────────────────────────── + + #[test] + fn test_mark_defaulted_success() { + let (env, client, admin, borrower, _) = setup(); + let loan_id = create_approved_loan(&env, &client, &admin, &borrower); + + // Advance time past repayment_due (30 days) + env.ledger().with_mut(|l| l.timestamp = 1_700_000_000); + + // Mark as defaulted + client.mark_defaulted(&loan_id); + + // Verify status + let loan = client.get_loan(&loan_id); + assert_eq!(loan.status, LoanStatus::Defaulted); + } + + #[test] + #[should_panic(expected = "loan is not past due")] + fn test_mark_defaulted_not_past_due() { + let (env, client, admin, borrower, _) = setup(); + let loan_id = create_approved_loan(&env, &client, &admin, &borrower); + + // Don't advance time -> loan is not past due + client.mark_defaulted(&loan_id); + } + + #[test] + #[should_panic(expected = "loan must be in Approved status")] + fn test_mark_defaulted_already_repaid() { + let (env, client, admin, borrower, _) = setup(); + let loan_id = create_approved_loan(&env, &client, &admin, &borrower); + + // Calculate total due (5% interest) + let principal = 10_000_0000000i128; + let interest = principal * 500 / 10_000; + let total_due = principal + interest; + + // Repay the loan + client.repay(&borrower, &loan_id, &total_due); + + // Advance time + env.ledger().with_mut(|l| l.timestamp = 1_700_000_000); + + // Try to mark as defaulted (should fail because already repaid) + client.mark_defaulted(&loan_id); + } + + #[test] + #[should_panic(expected = "loan not found")] + fn test_mark_defaulted_loan_not_found() { + let (env, client, _, _, _) = setup(); + client.mark_defaulted(&999); + } + + // ── edge cases ────────────────────────────────────────────────────────────── + + #[test] + #[should_panic] + fn test_approve_nonexistent_loan() { + let (env, client, admin, _, _) = setup(); + client.approve_loan(&admin, &999); + } + + #[test] + #[should_panic] + fn test_repay_nonexistent_loan() { + let (env, client, _, borrower, _) = setup(); + client.repay(&borrower, &999, &100); + } +} \ No newline at end of file diff --git a/contracts/treasury/src/lib.rs b/contracts/treasury/src/lib.rs index 786f8f7..a331b31 100644 --- a/contracts/treasury/src/lib.rs +++ b/contracts/treasury/src/lib.rs @@ -40,9 +40,6 @@ pub struct GroupInfo { pub is_active: bool, } -/// Complete snapshot of a single member, aggregated in one read-only call so the -/// frontend dashboard does not have to combine `get_members` and -/// `get_contributions` client-side (multiple RPC round-trips per member). #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct MemberSummary { @@ -61,7 +58,6 @@ pub struct TreasuryContract; #[contractimpl] impl TreasuryContract { - /// Initialize a new cooperative treasury group. pub fn initialize( env: Env, admin: Address, @@ -91,7 +87,6 @@ impl TreasuryContract { } } - /// Add a new member to the cooperative. pub fn add_member(env: Env, admin: Address, member: Address) { admin.require_auth(); Self::require_admin(&env, &admin); @@ -111,7 +106,46 @@ impl TreasuryContract { } } - /// Record a member contribution. Transfers USDC from member to this contract. + pub fn remove_member(env: Env, admin: Address, member: Address, force: bool) { + admin.require_auth(); + Self::require_admin(&env, &admin); + + let members: Vec
= env + .storage() + .instance() + .get(&DataKey::Members) + .unwrap_or_else(|| panic!("no members found")); + + if !members.contains(&member) { + panic!("member not found"); + } + + if !force { + let has_loan = false; + if has_loan { + panic!("member has pending loan, use force=true to override"); + } + } + + let mut new_members: Vec
= Vec::new(&env); + for m in members.iter() { + if m != member { + new_members.push_back(m.clone()); + } + } + + env.storage() + .instance() + .set(&DataKey::Members, &new_members); + + env.events().publish( + (Symbol::new(&env, "member_removed"),), + (member, env.ledger().timestamp()), + ); + + env.storage().instance().extend_ttl(100, 100); + } + pub fn contribute(env: Env, member: Address, amount: i128, period: u32) { member.require_auth(); Self::require_member(&env, &member); @@ -123,10 +157,8 @@ impl TreasuryContract { let asset: Address = env.storage().instance().get(&DataKey::AssetAddress).unwrap(); let token_client = token::Client::new(&env, &asset); - // Transfer from member wallet to this contract token_client.transfer(&member, &env.current_contract_address(), &amount); - // Record contribution let record = ContributionRecord { member: member.clone(), amount, @@ -142,7 +174,6 @@ impl TreasuryContract { env.storage().persistent() .set(&DataKey::Contributions(member.clone()), &history); - // Update total let total: i128 = env.storage().instance() .get(&DataKey::TotalContributions).unwrap_or(0); env.storage().instance() @@ -154,7 +185,87 @@ impl TreasuryContract { ); } - /// Withdraw funds — only callable by admin (e.g. for approved loans or expenses). + pub fn batch_contribute( + env: Env, + admin: Address, + contributions: Vec<(Address, i128, u32)>, + ) -> (u32, i128) { + admin.require_auth(); + Self::require_admin(&env, &admin); + + let mut valid_count: u32 = 0; + let mut total_amount: i128 = 0; + + let members: Vec
= env.storage().instance() + .get(&DataKey::Members) + .unwrap_or(Vec::new(&env)); + + let asset: Address = env.storage().instance().get(&DataKey::AssetAddress).unwrap(); + let token_client = token::Client::new(&env, &asset); + + let mut i: u32 = 0; + while i < contributions.len() { + let (member, amount, period) = contributions.get(i).unwrap(); + + if !members.contains(&member) { + env.events().publish( + (Symbol::new(&env, "skipped_non_member"),), + (member, "not a member"), + ); + i += 1; + continue; + } + + if amount <= 0i128 { + env.events().publish( + (Symbol::new(&env, "skipped_invalid_amount"),), + (member, "amount must be positive"), + ); + i += 1; + continue; + } + + // ✅ AUTORIZACIÓN DE CADA MIEMBRO + member.require_auth(); + + token_client.transfer(&member, &env.current_contract_address(), &amount); + + let record = ContributionRecord { + member: member.clone(), + amount: amount, + timestamp: env.ledger().timestamp(), + period: period, + }; + + let mut history: Vec = env + .storage().persistent() + .get(&DataKey::Contributions(member.clone())) + .unwrap_or(Vec::new(&env)); + history.push_back(record); + env.storage().persistent() + .set(&DataKey::Contributions(member.clone()), &history); + + valid_count += 1; + total_amount += amount; + + i += 1; + } + + let current_total: i128 = env.storage().instance() + .get(&DataKey::TotalContributions).unwrap_or(0); + env.storage().instance() + .set(&DataKey::TotalContributions, &(current_total + total_amount)); + + env.events().publish( + (Symbol::new(&env, "batch_contribution"),), + (valid_count, total_amount, env.ledger().timestamp()), + ); + + env.storage().instance().extend_ttl(100, 100); + + (valid_count, total_amount) + } + pub fn withdraw(env: Env, admin: Address, to: Address, amount: i128) { admin.require_auth(); Self::require_admin(&env, &admin); @@ -169,28 +280,24 @@ impl TreasuryContract { ); } - /// Get current treasury balance. pub fn balance(env: Env) -> i128 { let asset: Address = env.storage().instance().get(&DataKey::AssetAddress).unwrap(); let token_client = token::Client::new(&env, &asset); token_client.balance(&env.current_contract_address()) } - /// Get all members. pub fn get_members(env: Env) -> Vec
{ env.storage().instance() .get(&DataKey::Members) .unwrap_or(Vec::new(&env)) } - /// Get contribution history for a member. pub fn get_contributions(env: Env, member: Address) -> Vec { env.storage().persistent() .get(&DataKey::Contributions(member)) .unwrap_or(Vec::new(&env)) } - /// Get full group info. pub fn get_info(env: Env) -> GroupInfo { let members: Vec
= env.storage().instance() .get(&DataKey::Members) @@ -206,17 +313,6 @@ impl TreasuryContract { } } - /// Aggregate a member's full picture in a single read-only call. - /// - /// Combines membership status with stats derived from the member's stored - /// contribution history: total contributed, number of contributions, and the - /// period / ledger timestamp of the most recent one. This lets the dashboard - /// render a member row with one RPC instead of `get_members` + - /// `get_contributions`. - /// - /// Read-only — no auth required. An unknown address (or a member who has not - /// contributed yet) returns zeroed stats and never panics; `is_member` - /// reflects whether the address is in the members list regardless. pub fn get_member_summary(env: Env, member: Address) -> MemberSummary { let members: Vec
= env .storage().instance() @@ -290,7 +386,6 @@ mod tests { let asset = env.register_stellar_asset_contract_v2(token_admin.clone()); let asset_address = asset.address(); - // Fund member StellarAssetClient::new(&env, &asset_address) .mint(&member, &10_000_0000000i128); @@ -311,7 +406,7 @@ mod tests { client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); client.add_member(&admin, &member); - let amount = 100_0000000i128; // 100 USDC (7 decimals) + let amount = 100_0000000i128; client.contribute(&member, &amount, &1); let balance = client.balance(); @@ -322,8 +417,6 @@ mod tests { assert_eq!(history.get(0).unwrap().amount, amount); } - // ── initialize edge cases ──────────────────────────────────────────────── - #[test] #[should_panic] fn test_double_initialize() { @@ -332,8 +425,6 @@ mod tests { client.initialize(&admin, &String::from_str(&env, "Test Coop 2"), &asset); } - // ── add_member edge cases ──────────────────────────────────────────────── - #[test] #[should_panic] fn test_add_member_unauthorized() { @@ -352,7 +443,55 @@ mod tests { assert_eq!(client.get_members().len(), 1); } - // ── contribute edge cases ──────────────────────────────────────────────── + #[test] + fn test_remove_member_happy_path() { + let (env, client, admin, member, asset) = setup(); + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + client.add_member(&admin, &member); + + let members = client.get_members(); + assert_eq!(members.len(), 1); + assert_eq!(members.get(0).unwrap(), member); + + client.remove_member(&admin, &member, &false); + + let members_after = client.get_members(); + assert_eq!(members_after.len(), 0); + } + + #[test] + #[should_panic(expected = "member not found")] + fn test_remove_nonexistent_member() { + let (env, client, admin, member, asset) = setup(); + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + client.remove_member(&admin, &member, &false); + } + + #[test] + #[should_panic] + fn test_remove_member_unauthorized() { + let (env, client, admin, member, asset) = setup(); + let non_admin = Address::generate(&env); + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + client.add_member(&admin, &member); + client.remove_member(&non_admin, &member, &false); + } + + #[test] + fn test_remove_member_preserves_contribution_history() { + let (env, client, admin, member, asset) = setup(); + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + client.add_member(&admin, &member); + + let amount = 100_0000000i128; + client.contribute(&member, &amount, &1); + + client.remove_member(&admin, &member, &false); + + let history = client.get_contributions(&member); + assert_eq!(history.len(), 1); + assert_eq!(history.get(0).unwrap().amount, amount); + } #[test] #[should_panic] @@ -381,8 +520,6 @@ mod tests { client.contribute(&non_member, &100_0000000i128, &1); } - // ── withdraw happy path + edge cases ──────────────────────────────────── - #[test] fn test_withdraw_happy_path() { let (env, client, admin, member, asset) = setup(); @@ -426,8 +563,6 @@ mod tests { client.withdraw(&admin, &recipient, &(deposit + 1)); } - // ── query functions ────────────────────────────────────────────────────── - #[test] fn test_balance_initial_is_zero() { let (env, client, admin, _, asset) = setup(); @@ -490,8 +625,6 @@ mod tests { assert!(info.is_active); } - // ── multi-member scenario ──────────────────────────────────────────────── - #[test] fn test_multiple_members_contribute_independently() { let (env, client, admin, member1, asset) = setup(); @@ -516,8 +649,6 @@ mod tests { assert_eq!(info.member_count, 2); } - // ── timestamp recording ────────────────────────────────────────────────── - #[test] fn test_contribute_records_ledger_timestamp() { let (env, client, admin, member, asset) = setup(); @@ -532,8 +663,6 @@ mod tests { assert_eq!(record.timestamp, ts); } - // ── get_member_summary ─────────────────────────────────────────────────── - #[test] fn test_get_member_summary_known_member_with_contributions() { let (env, client, admin, member, asset) = setup(); @@ -550,7 +679,6 @@ mod tests { assert!(summary.is_member); assert_eq!(summary.total_contributed, 350_0000000i128); assert_eq!(summary.contribution_count, 2); - // Reflects the most recent contribution. assert_eq!(summary.last_period, 3); assert_eq!(summary.last_contributed_at, ts); } @@ -583,4 +711,76 @@ mod tests { assert_eq!(summary.last_period, 0); assert_eq!(summary.last_contributed_at, 0); } -} + + // ── batch_contribute tests ────────────────────────────────────────────────── + + #[test] + fn test_batch_contribute_success() { + let (env, client, admin, member1, asset) = setup(); + let member2 = Address::generate(&env); + + StellarAssetClient::new(&env, &asset) + .mint(&member2, &10_000_0000000i128); + + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + client.add_member(&admin, &member1); + client.add_member(&admin, &member2); + + let mut contributions = Vec::new(&env); + contributions.push_back((member1.clone(), 100_0000000i128, 1u32)); + contributions.push_back((member2.clone(), 200_0000000i128, 1u32)); + + let (count, total) = client.batch_contribute(&admin, &contributions); + + assert_eq!(count, 2); + assert_eq!(total, 300_0000000i128); + assert_eq!(client.balance(), 300_0000000i128); + } + + #[test] + fn test_batch_contribute_partial_skip() { + let (env, client, admin, member1, asset) = setup(); + let non_member = Address::generate(&env); + + StellarAssetClient::new(&env, &asset) + .mint(&non_member, &10_000_0000000i128); + + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + client.add_member(&admin, &member1); + + let mut contributions = Vec::new(&env); + contributions.push_back((member1.clone(), 100_0000000i128, 1u32)); + contributions.push_back((non_member.clone(), 200_0000000i128, 1u32)); + + let (count, total) = client.batch_contribute(&admin, &contributions); + + assert_eq!(count, 1); + assert_eq!(total, 100_0000000i128); + assert_eq!(client.balance(), 100_0000000i128); + } + + #[test] + fn test_batch_contribute_empty() { + let (env, client, admin, _, asset) = setup(); + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + + let contributions = Vec::new(&env); + + let (count, total) = client.batch_contribute(&admin, &contributions); + + assert_eq!(count, 0); + assert_eq!(total, 0); + assert_eq!(client.balance(), 0); + } + + #[test] + #[should_panic] + fn test_batch_contribute_unauthorized() { + let (env, client, admin, _, asset) = setup(); + let non_admin = Address::generate(&env); + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + + let contributions = Vec::new(&env); + client.batch_contribute(&non_admin, &contributions); + } +} \ No newline at end of file