Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions contracts/creditline-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
59 changes: 49 additions & 10 deletions contracts/creditline-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down
Loading