diff --git a/contracts/creditline-contract/src/lib.rs b/contracts/creditline-contract/src/lib.rs index 573c77c..813ac09 100644 --- a/contracts/creditline-contract/src/lib.rs +++ b/contracts/creditline-contract/src/lib.rs @@ -639,14 +639,29 @@ impl CreditLineContract { loan.guarantee_amount, ); + // Policy: Option (a) - Propagate failure. + // If a reputation contract is configured (Some), the default penalty score update + // must succeed atomically with the loan default. If the score update call fails, + // we emit a ScoreUpdateFailed event and panic, reverting the entire transaction + // so loan state (Defaulted) and reputation score can never diverge. + // Note: If no reputation contract is configured (None), score updates are + // intentionally skipped as the protocol is operating without on-chain reputation integration. if let Some(reputation_contract) = storage::get_reputation_contract(&env)? { let penalty = Self::calculate_default_penalty(&env, &loan); let updater = env.current_contract_address(); - let _ = env.try_invoke_contract::<(), soroban_sdk::Error>( + let res = env.try_invoke_contract::<(), soroban_sdk::Error>( &reputation_contract, &Symbol::new(&env, "decrease_score"), - (updater, loan.borrower, penalty).into_val(&env), + (updater, loan.borrower.clone(), penalty).into_val(&env), ); + let call_succeeded = match res { + Ok(Ok(())) => true, + _ => false, + }; + if !call_succeeded { + events::emit_score_update_failed(&env, &loan.borrower, false, penalty); + panic_with_error!(&env, CreditLineError::ReputationCallFailed); + } } Self::exit_non_reentrant(&env); @@ -1130,6 +1145,12 @@ impl CreditLineContract { Ok(()) } + /// Handles increasing reputation score upon full loan repayment. + /// + /// Policy: Option (a) - Propagate failure. + /// The score reward update must succeed atomically with full loan repayment. + /// If the score update call fails (reverts), we emit a ScoreUpdateFailed event and panic, + /// reverting the entire repayment transaction so loan state and reputation score can never diverge. fn handle_reputation_increase( env: &Env, reputation_contract: &Address, @@ -1139,11 +1160,19 @@ impl CreditLineContract { due_date: u64, ) { let score_increase: u32 = if payment_date < due_date { 15 } else { 10 }; - let _ = env.try_invoke_contract::<(), soroban_sdk::Error>( + let res = env.try_invoke_contract::<(), soroban_sdk::Error>( reputation_contract, &Symbol::new(env, "increase_score"), (updater, borrower, score_increase).into_val(env), ); + let call_succeeded = match res { + Ok(Ok(())) => true, + _ => false, + }; + if !call_succeeded { + events::emit_score_update_failed(env, borrower, true, score_increase); + panic_with_error!(env, CreditLineError::ReputationCallFailed); + } } fn get_protocol_parameters(env: &Env) -> ProtocolParameters { diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index 20ebfe3..32b6631 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -3173,10 +3173,10 @@ fn test_partial_repayment_does_not_change_reputation_score() { } #[test] -fn test_reputation_call_failure_does_not_block_repayment() { - // Even if the reputation contract call fails, the loan repayment must succeed - // We verify this by removing the creditline as a reputation updater and - // confirming the loan still moves to Paid status. +fn test_reputation_call_failure_reverts_repayment() { + // Policy (a): Revert surrounding operation on reputation call failure. + // If the reputation contract call fails, the repayment transaction must revert completely + // so loan status and score never diverge. let t = RealIntegrationCtx::setup(); let provider = Address::generate(&t.env); let user = Address::generate(&t.env); @@ -3200,13 +3200,52 @@ fn test_reputation_call_failure_does_not_block_repayment() { // Revoke updater permission so the increase_score call will fail t.reputation.set_updater(&t.admin, &t.creditline_id, &false); - // Repayment must still succeed despite the reputation call failure - t.creditline.repay_loan(&user, &loan_id, &total_due); + // Repayment must fail with ReputationCallFailed + let res = t.creditline.try_repay_loan(&user, &loan_id, &total_due); + assert_eq!(res, Err(Ok(CreditLineError::ReputationCallFailed))); - let loan = t.creditline.get_loan(&loan_id); - assert_eq!(loan.status, LoanStatus::Paid); - assert_eq!(loan.remaining_balance, 0); - // Score unchanged because the call was silently ignored + // Loan status remains Active and balance remains unchanged because transaction reverted + let loan_after = t.creditline.get_loan(&loan_id); + assert_eq!(loan_after.status, LoanStatus::Active); + assert_eq!(loan_after.remaining_balance, total_due); + assert_eq!(t.reputation.get_score(&user), 60); +} + +#[test] +fn test_reputation_call_failure_reverts_default() { + // Policy (a): Revert surrounding operation on reputation call failure. + // If the reputation contract call fails during mark_defaulted(), the default transaction must revert completely. + let t = RealIntegrationCtx::setup(); + let provider = Address::generate(&t.env); + let user = Address::generate(&t.env); + let vendor = Address::generate(&t.env); + + t.fund_pool(&provider, 10_000); + t.register_vendor(&vendor, "Default Vendor"); + t.set_score(&user, 60); + t.mint(&user, 500); + + let now = t.env.ledger().timestamp(); + let schedule = t.single_installment(1_000, now + 1_000); + let loan_id = + t.creditline + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + + // Advance time past grace period + t.env + .ledger() + .set_timestamp(now + 1_000 + 86_400 * 7 + 1); + + // Revoke creditline updater permission on reputation contract + t.reputation.set_updater(&t.admin, &t.creditline_id, &false); + + // Defaulting must fail with ReputationCallFailed + let res = t.creditline.try_mark_defaulted(&loan_id); + assert_eq!(res, Err(Ok(CreditLineError::ReputationCallFailed))); + + // Loan status remains Active because default transaction reverted + let loan_after = t.creditline.get_loan(&loan_id); + assert_eq!(loan_after.status, LoanStatus::Active); assert_eq!(t.reputation.get_score(&user), 60); }