From 618724ac200fd304b8e4dca129a6f051d306830e Mon Sep 17 00:00:00 2001 From: John Imeobong Date: Sat, 20 Jun 2026 01:41:22 +0000 Subject: [PATCH] feat(loan_manager): add repayment below minimum error handling and update tests --- lending_pool/src/lib.rs | 17 ++++++++++++----- lending_pool/src/test.rs | 6 +++--- loan_manager/src/lib.rs | 15 +++++++++++---- loan_manager/src/test.rs | 15 +++++++++++++-- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/lending_pool/src/lib.rs b/lending_pool/src/lib.rs index 47845ce..f291398 100644 --- a/lending_pool/src/lib.rs +++ b/lending_pool/src/lib.rs @@ -21,6 +21,7 @@ pub enum PoolError { NoProposedAdmin = 10, CooldownTooLong = 11, NotPaused = 12, + WithdrawalCooldownActive = 13, } /// Storage keys. @@ -231,20 +232,26 @@ impl LendingPool { .ok_or(PoolError::InvalidAmount) } - fn assert_withdrawal_cooldown_elapsed(env: &Env, provider: &Address, token: &Address) { + fn assert_withdrawal_cooldown_elapsed( + env: &Env, + provider: &Address, + token: &Address, + ) -> Result<(), PoolError> { let cooldown = Self::withdrawal_cooldown(env); if cooldown == 0 { - return; + return Ok(()); } let Some(deposit_ledger) = Self::read_deposit_timestamp(env, provider, token) else { - return; + return Ok(()); }; let current_ledger = env.ledger().sequence(); if current_ledger < deposit_ledger.saturating_add(cooldown) { - panic!("withdrawal_cooldown_active"); + return Err(PoolError::WithdrawalCooldownActive); } + + Ok(()) } /// Burns `shares` for `provider` and transfers out the proportional @@ -608,7 +615,7 @@ impl LendingPool { ) -> Result<(), PoolError> { provider.require_auth(); Self::assert_not_paused(&env)?; - Self::assert_withdrawal_cooldown_elapsed(&env, &provider, &token); + Self::assert_withdrawal_cooldown_elapsed(&env, &provider, &token)?; let assets = Self::redeem_shares(&env, &provider, &token, shares)?; withdraw(&env, provider, token, assets, shares); Ok(()) diff --git a/lending_pool/src/test.rs b/lending_pool/src/test.rs index c3b588f..4b704dc 100644 --- a/lending_pool/src/test.rs +++ b/lending_pool/src/test.rs @@ -194,8 +194,7 @@ fn test_insufficient_balance_withdraw_panic() { } #[test] -#[should_panic(expected = "withdrawal_cooldown_active")] -fn test_immediate_withdraw_panics_when_cooldown_active() { +fn test_withdraw_returns_cooldown_error_when_cooldown_active() { let env = Env::default(); env.mock_all_auths(); @@ -210,7 +209,8 @@ fn test_immediate_withdraw_panics_when_cooldown_active() { stellar_asset_client.mint(&provider, &5_000); pool_client.deposit(&provider, &token_id, &1_000); - pool_client.withdraw(&provider, &token_id, &1_000); + let result = pool_client.try_withdraw(&provider, &token_id, &1_000); + assert_eq!(result, Err(Ok(crate::PoolError::WithdrawalCooldownActive))); } #[test] diff --git a/loan_manager/src/lib.rs b/loan_manager/src/lib.rs index a80aa45..299c162 100644 --- a/loan_manager/src/lib.rs +++ b/loan_manager/src/lib.rs @@ -60,6 +60,7 @@ pub enum LoanError { InvalidExtension = 25, InsufficientCollateral = 26, LoanNotLiquidatable = 27, + RepaymentBelowMinimum = 28, } #[contracttype] @@ -513,6 +514,10 @@ impl LoanManager { .checked_add(delta) .expect("total outstanding overflow"); + // This can only become negative if the contract state is inconsistent: + // repayment logic only ever subtracts the exact principal amount of a + // fully repaid loan, and the corresponding loan-approval bookkeeping + // prevents a second subtraction from the same outstanding balance. if updated < 0 { panic!("total outstanding underflow"); } @@ -1246,7 +1251,7 @@ impl LoanManager { let is_rounding_dust_forgiveness = total_debt <= min_repayment_amount; if amount < total_debt && amount < min_repayment_amount && !is_rounding_dust_forgiveness { - panic!("repayment amount below minimum"); + return Err(LoanError::RepaymentBelowMinimum); } let token: Address = env @@ -2012,16 +2017,16 @@ impl LoanManager { Self::max_loan_amount(&env) } - pub fn set_min_repayment_amount(env: Env, amount: i128) { + pub fn set_min_repayment_amount(env: Env, amount: i128) -> Result<(), LoanError> { if amount < 0 { - panic!("min repayment amount cannot be negative"); + return Err(LoanError::InvalidAmount); } let admin: Address = env .storage() .instance() .get(&DataKey::Admin) - .expect("not initialized"); + .ok_or(LoanError::NotInitialized)?; admin.require_auth(); let old_amount = Self::min_repayment_amount(&env); @@ -2030,6 +2035,8 @@ impl LoanManager { .set(&DataKey::MinRepaymentAmount, &amount); Self::bump_instance_ttl(&env); events::min_repayment_updated(&env, admin, old_amount, amount); + + Ok(()) } pub fn get_min_repayment_amount(env: Env) -> i128 { diff --git a/loan_manager/src/test.rs b/loan_manager/src/test.rs index 4cc0781..0150119 100644 --- a/loan_manager/src/test.rs +++ b/loan_manager/src/test.rs @@ -677,7 +677,6 @@ fn test_partial_repayment_tracks_split_balances() { } #[test] -#[should_panic(expected = "repayment amount below minimum")] fn test_minimum_repayment_amount_enforced() { let env = Env::default(); env.mock_all_auths_allowing_non_root_auth(); @@ -704,7 +703,19 @@ fn test_minimum_repayment_amount_enforced() { manager.approve_loan(&loan_id); manager.set_min_repayment_amount(&150); - manager.repay(&borrower, &loan_id, &100); + let result = manager.try_repay(&borrower, &loan_id, &100); + assert_eq!(result, Err(Ok(LoanError::RepaymentBelowMinimum))); +} + +#[test] +fn test_set_min_repayment_amount_rejects_negative_values() { + let env = Env::default(); + env.mock_all_auths(); + + let (manager, _nft_client, _pool_client, _token_id, _token_admin) = setup_test(&env); + + let result = manager.try_set_min_repayment_amount(&-1); + assert_eq!(result, Err(Ok(LoanError::InvalidAmount))); } #[test]