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
14 changes: 14 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,20 @@ Update this file after every completed contract change, fix, or architectural de

## Recently Fixed

### Security: Contract Pause & Unpause Emergency Stop Mechanism
- **Problem:** Neither `liquidity-pool-contract` nor `creditline-contract` implemented a pause/unpause emergency stop mechanism. During an exploit or bad parameterization, there was no way to halt state transitions (deposits, withdrawals, loan funding, loan creation, defaults) without an emergency WASM upgrade.
- **Fix:**
- Implemented `paused: bool` in instance storage (`storage::is_paused`, `storage::set_paused`).
- Added `pause(env, admin)` and `unpause(env, admin)` functions restricted to contract admin (`admin.require_auth()` as the literal first line), emitting `PAUSED` and `UNPAUSED` events.
- Added `is_paused(env) -> bool` view function.
- Guarded all state-mutating entry points (`deposit`, `withdraw`, `fund_loan`, `receive_guarantee`, `absorb_loss`, `distribute_interest`, `accumulate_interest` in `liquidity-pool-contract`; `create_loan`, `request_loan`, `approve_loan`, `cancel_loan`, `mark_defaulted`, `warn_grace_period` in `creditline-contract`) with `require_not_paused(&env)` helper.
- **Repayment Exception Policy:** Loan repayments (`repay_loan`, `repay_installment`, `receive_repayment`) intentionally bypass pause checks so borrowers are not penalized with accrued late fees or forced defaults during an administrative freeze. Documented in code comments and unit tests.
- Query functions (`get_share_price`, `get_pool_stats`, `get_lp_shares`, `calculate_withdrawal`, `get_loan`, `get_user_loans`, `get_user_active_debt`, `get_version`, `get_admin`) remain 100% accessible while paused.
- Added `ContractPaused = 15` variant to `LiquidityPoolError` and `ContractPaused = 33` variant to `CreditLineError`.
- **Files:** `contracts/liquidity-pool-contract/src/lib.rs`, `contracts/liquidity-pool-contract/src/storage.rs`, `contracts/liquidity-pool-contract/src/events.rs`, `contracts/liquidity-pool-contract/src/errors.rs`, `contracts/liquidity-pool-contract/src/tests.rs`, `contracts/creditline-contract/src/lib.rs`, `contracts/creditline-contract/src/storage.rs`, `contracts/creditline-contract/src/events.rs`, `contracts/creditline-contract/src/errors.rs`, `contracts/creditline-contract/src/tests.rs`, `context/progress-tracker.md`
- **New tests:** Comprehensive unit tests in both contract suites testing admin-only pause/unpause, mutating function rejection with `ContractPaused`, repayment exception policy execution, and query availability while paused.
- **Verification:** All 142 tests in `creditline-contract`, 109 tests in `liquidity-pool-contract`, and 393 tests across the entire workspace passed with zero failures.

### Security: `cancel_loan()` Ordering Discipline, Reentrancy Guard & Pre-Flight Check
- **Problem:** `cancel_loan()` in `creditline-contract` violated contract ordering discipline by omitting `enter_non_reentrant`/`exit_non_reentrant` guards, executing outbound token transfers before mutating status to `Cancelled` and persisting state, and missing token balance pre-flight validation (causing raw token panic on underfunded contract balance).
- **Fix:**
Expand Down
2 changes: 2 additions & 0 deletions contracts/creditline-contract/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,6 @@ pub enum CreditLineError {
UpgradeTimelockNotMet = 29,
UpgradeHashMismatch = 30,
InsufficientRefundBalance = 31,
ReputationCallFailed = 32,
ContractPaused = 33,
}
27 changes: 27 additions & 0 deletions contracts/creditline-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,30 @@ pub fn emit_upgrade_proposed(
(wasm_hash.clone(), proposed_at, unlock_at),
);
}

/// Emit an event when a cross-contract reputation score update fails.
pub fn emit_score_update_failed(
env: &Env,
borrower: &Address,
is_increase: bool,
amount: u32,
) {
env.events().publish(
(Symbol::new(env, "ScoreUpdateFailed"), borrower.clone()),
(is_increase, amount, env.ledger().timestamp()),
);
}

pub fn emit_paused(env: &Env, admin: &Address) {
env.events().publish(
(symbol_short!("PAUSED"), admin.clone()),
env.ledger().timestamp(),
);
}

pub fn emit_unpaused(env: &Env, admin: &Address) {
env.events().publish(
(symbol_short!("UNPAUSED"), admin.clone()),
env.ledger().timestamp(),
);
}
40 changes: 40 additions & 0 deletions contracts/creditline-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ impl CreditLineContract {
loan_type: LoanType,
) -> Result<u64, CreditLineError> {
user.require_auth();
Self::require_not_paused(&env);

Self::validate_guarantee(&env, total_amount, guarantee_amount)?;
Self::validate_vendor(&env, &vendor)?;
Expand Down Expand Up @@ -132,6 +133,7 @@ impl CreditLineContract {
loan_type: LoanType,
) -> Result<u64, CreditLineError> {
user.require_auth();
Self::require_not_paused(&env);

Self::validate_guarantee(&env, total_amount, guarantee_amount)?;
let score = Self::validate_reputation(&env, &user)?;
Expand Down Expand Up @@ -283,6 +285,30 @@ impl CreditLineContract {
storage::set_parameters_contract(&env, &address);
}

pub fn pause(env: Env, admin: Address) {
admin.require_auth();
access::require_admin(&env, &admin);
storage::set_paused(&env, true);
events::emit_paused(&env, &admin);
}

pub fn unpause(env: Env, admin: Address) {
admin.require_auth();
access::require_admin(&env, &admin);
storage::set_paused(&env, false);
events::emit_unpaused(&env, &admin);
}

pub fn is_paused(env: Env) -> bool {
storage::is_paused(&env)
}

fn require_not_paused(env: &Env) {
if storage::is_paused(env) {
panic_with_error!(env, CreditLineError::ContractPaused);
}
}

fn validate_guarantee(
env: &Env,
total_amount: i128,
Expand Down Expand Up @@ -504,6 +530,7 @@ impl CreditLineContract {
/// `LoanNotActive` if the loan is not active. Returns `Ok(())` when the
/// warning event was successfully emitted (i.e. the loan is in the grace window).
pub fn warn_grace_period(env: Env, loan_id: u64) -> Result<(), CreditLineError> {
Self::require_not_paused(&env);
let loan = storage::read_loan(&env, loan_id)?;

if loan.status != LoanStatus::Active {
Expand Down Expand Up @@ -543,6 +570,7 @@ impl CreditLineContract {
}

pub fn mark_defaulted(env: Env, loan_id: u64) -> Result<(), CreditLineError> {
Self::require_not_paused(&env);
let mut loan = storage::read_loan(&env, loan_id)?;

if loan.status != LoanStatus::Active {
Expand Down Expand Up @@ -629,6 +657,7 @@ impl CreditLineContract {
// 1. Admin auth - must be first
let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err));
admin.require_auth();
Self::require_not_paused(&env);

// 2. Load loan — panic if not found
let mut loan =
Expand Down Expand Up @@ -701,6 +730,7 @@ impl CreditLineContract {

pub fn cancel_loan(env: Env, caller: Address, loan_id: u64) -> Result<(), CreditLineError> {
caller.require_auth();
Self::require_not_paused(&env);

let mut loan = storage::read_loan(&env, loan_id)?;

Expand Down Expand Up @@ -747,6 +777,11 @@ impl CreditLineContract {
) -> Result<i128, CreditLineError> {
borrower.require_auth();

// Repayment Exception Policy:
// Repayments intentionally bypass the contract pause check.
// This ensures borrowers are not blocked from settling debt or penalized
// with accrued fees during an emergency administrative pause.

let mut loan = storage::read_loan(&env, loan_id)?;

if loan.borrower != borrower {
Expand Down Expand Up @@ -865,6 +900,11 @@ impl CreditLineContract {
) -> Result<i128, CreditLineError> {
borrower.require_auth();

// Repayment Exception Policy:
// Repayments intentionally bypass the contract pause check.
// This ensures borrowers are not blocked from settling debt or penalized
// with accrued fees during an emergency administrative pause.

let mut loan = storage::read_loan(&env, loan_id)?;

if loan.borrower != borrower {
Expand Down
11 changes: 11 additions & 0 deletions contracts/creditline-contract/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,14 @@ pub fn set_pending_upgrade(env: &Env, upgrade: &crate::types::PendingUpgrade) {
pub fn clear_pending_upgrade(env: &Env) {
env.storage().instance().remove(&PENDING_UPGRADE_KEY);
}

// --- Paused State ---
pub const PAUSED_KEY: Symbol = symbol_short!("PAUSED");

pub fn is_paused(env: &Env) -> bool {
env.storage().instance().get(&PAUSED_KEY).unwrap_or(false)
}

pub fn set_paused(env: &Env, paused: bool) {
env.storage().instance().set(&PAUSED_KEY, &paused);
}
97 changes: 97 additions & 0 deletions contracts/creditline-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4253,3 +4253,100 @@ fn test_cancel_loan_state_persists_before_transfer() {
let loan_after = ctx.client.get_loan(&loan_id);
assert_eq!(loan_after.status, LoanStatus::Cancelled);
}

// -----------------------------------------------------------------------------
// Pause / Unpause Emergency Stop Mechanism Tests
// -----------------------------------------------------------------------------

#[test]
fn test_creditline_pause_unpause_requires_admin() {
let t = TestCtx::setup();
let non_admin = Address::generate(&t.env);

assert_eq!(t.client.is_paused(), false);

// Non-admin cannot pause
let expected_err = soroban_sdk::Error::from_contract_error(CreditLineError::NotAdmin as u32);
assert_eq!(t.client.try_pause(&non_admin), Err(Ok(expected_err)));

// Admin can pause
t.client.pause(&t.admin);
assert_eq!(t.client.is_paused(), true);

// Non-admin cannot unpause
assert_eq!(t.client.try_unpause(&non_admin), Err(Ok(expected_err)));

// Admin can unpause
t.client.unpause(&t.admin);
assert_eq!(t.client.is_paused(), false);
}

#[test]
fn test_creditline_paused_state_blocks_mutating_functions_and_allows_repayment_and_queries() {
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, "Pause Vendor");
t.set_score(&user, 60);
t.mint(&user, 5_000);

let now = t.env.ledger().timestamp();
let schedule = t.single_installment(1_000, now + 10_000);
let loan_id =
t.creditline
.create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard);

let pending_id =
t.creditline
.request_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard);

// Admin pauses creditline contract
t.creditline.pause(&t.admin);
assert_eq!(t.creditline.is_paused(), true);

let expected_paused_err = soroban_sdk::Error::from_contract_error(CreditLineError::ContractPaused as u32);

// Mutating functions blocked with ContractPaused
assert_eq!(
t.creditline.try_create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard),
Err(Ok(CreditLineError::ContractPaused))
);
assert_eq!(
t.creditline.try_request_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard),
Err(Ok(CreditLineError::ContractPaused))
);
assert_eq!(
t.creditline.try_approve_loan(&pending_id),
Err(Ok(expected_paused_err))
);
assert_eq!(
t.creditline.try_cancel_loan(&user, &pending_id),
Err(Ok(CreditLineError::ContractPaused))
);
assert_eq!(
t.creditline.try_mark_defaulted(&loan_id),
Err(Ok(CreditLineError::ContractPaused))
);
assert_eq!(
t.creditline.try_warn_grace_period(&loan_id),
Err(Ok(CreditLineError::ContractPaused))
);

// Repayment Exception Policy: repay_loan and repay_installment are NOT blocked by pause
t.mint(&user, 500);
assert!(t.creditline.try_repay_loan(&user, &loan_id, &500).is_ok());
assert!(t.creditline.try_repay_installment(&user, &loan_id, &0, &500).is_ok());

// Query functions work fine while paused
assert_eq!(t.creditline.get_loan(&loan_id).loan_id, loan_id);
assert_eq!(t.creditline.get_user_loans(&user, &0, &10).len(), 2);
assert_eq!(t.creditline.get_admin(), t.admin);
assert_eq!(t.creditline.get_version(), 1);

// Unpause restores operations
t.creditline.unpause(&t.admin);
assert_eq!(t.creditline.is_paused(), false);
}
1 change: 1 addition & 0 deletions contracts/liquidity-pool-contract/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ pub enum LiquidityPoolError {
UpgradeNotProposed = 12,
UpgradeTimelockNotMet = 13,
UpgradeHashMismatch = 14,
ContractPaused = 15,
}
14 changes: 14 additions & 0 deletions contracts/liquidity-pool-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,17 @@ pub fn emit_upgrade_proposed(
(wasm_hash.clone(), proposed_at, unlock_at),
);
}

pub fn emit_paused(env: &Env, admin: &Address) {
env.events().publish(
(symbol_short!("PAUSED"), admin),
env.ledger().timestamp(),
);
}

pub fn emit_unpaused(env: &Env, admin: &Address) {
env.events().publish(
(symbol_short!("UNPAUSED"), admin),
env.ledger().timestamp(),
);
}
Loading
Loading