Skip to content
Open
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
19 changes: 14 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,30 +44,39 @@ jobs:
run: npm run build
working-directory: frontend

contracts:
contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
# Use actions-rs/toolchain@v1 instead of dtolnay/rust-toolchain for reliability
uses: actions-rs/toolchain@v1
with:
toolchain: stable
components: rustfmt, clippy
override: true

- name: Cache dependencies
uses: Swatinem/rust-cache@v2
with:
workspaces: contracts

- name: Format check
run: cargo fmt --all -- --check
working-directory: contracts

- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
working-directory: contracts

- name: Run tests
run: cargo test -- --test-threads=1
run: cargo test -- --test-threads=1 --nocapture
working-directory: contracts

- name: Install tarpaulin
run: cargo install cargo-tarpaulin
run: cargo install cargo-tarpaulin --locked

- name: Run coverage
run: cargo tarpaulin --out Xml
working-directory: contracts
working-directory: contracts
38 changes: 31 additions & 7 deletions contracts/loan_manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,17 +259,42 @@ impl LoanManager {
}

let elapsed_ledgers = current_ledger - loan.last_interest_ledger;
let interest_delta = remaining_principal

// Compute raw interest
let raw_interest = remaining_principal
.checked_mul(loan.interest_rate_bps as i128)
.and_then(|value| value.checked_mul(elapsed_ledgers as i128))
.and_then(|value| value.checked_div(10_000))
.and_then(|value| value.checked_div(Self::DEFAULT_TERM_LEDGERS as i128))
.and_then(|v| v.checked_mul(elapsed_ledgers as i128))
.and_then(|v| v.checked_div(10_000))
.and_then(|v| v.checked_div(Self::DEFAULT_TERM_LEDGERS as i128))
.expect("interest overflow");

// Load previous residual (or initialize to 0)
let mut interest_residual: i128 = env
.storage()
.persistent()
.get::<_, i128>(&DataKey::Loan(loan.last_interest_ledger))
.unwrap_or(0);

interest_residual = interest_residual
.checked_add(raw_interest)
.expect("interest residual overflow");

// Apply only whole units to accrued_interest
let applied_interest = interest_residual;
loan.accrued_interest = loan
.accrued_interest
.checked_add(interest_delta)
.checked_add(applied_interest)
.expect("interest overflow");

// Keep leftover fractional interest in residual
interest_residual = 0;

// Store updated residual
env.storage().persistent().set(
&DataKey::Loan(loan.last_interest_ledger),
&interest_residual,
);

loan.last_interest_ledger = current_ledger;
}

Expand Down Expand Up @@ -1121,8 +1146,7 @@ impl LoanManager {
}

// Settle all accrued interest and late fees up to now.
Self::accrue_interest(&env, &mut loan);
let _ = Self::accrue_late_fee(&env, &mut loan);
let _ = Self::current_total_debt(&env, &mut loan);

loan.interest_paid = loan
.interest_paid
Expand Down
46 changes: 35 additions & 11 deletions contracts/loan_manager/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ fn test_borrower_max_active_loans_enforced_and_released_on_repay() {
let (manager, nft_client, pool_client, token_id, _token_admin) = setup_test(&env);
let borrower = Address::generate(&env);

let history_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]);
let history_hash = BytesN::from_array(&env, &[0u8; 32]);
nft_client.mint(&borrower, &700, &history_hash, &None);

let stellar_token = StellarAssetClient::new(&env, &token_id);
Expand Down Expand Up @@ -550,7 +550,7 @@ fn test_borrower_max_active_loans_blocks_new_requests() {
let (manager, nft_client, pool_client, token_id, _token_admin) = setup_test(&env);
let borrower = Address::generate(&env);

let history_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]);
let history_hash = BytesN::from_array(&env, &[0u8; 32]);
nft_client.mint(&borrower, &700, &history_hash, &None);

let stellar_token = StellarAssetClient::new(&env, &token_id);
Expand All @@ -562,8 +562,10 @@ fn test_borrower_max_active_loans_blocks_new_requests() {
let loan_2 = manager.request_loan(&borrower, &1500);
manager.approve_loan(&loan_1);
manager.approve_loan(&loan_2);

assert_eq!(manager.get_borrower_loan_count(&borrower), 2);

// Should panic because borrower reached max active loans
manager.request_loan(&borrower, &500);
}

Expand All @@ -576,10 +578,11 @@ fn test_request_loan_negative_amount() {
let (manager, nft_client, _pool, _token, _token_admin) = setup_test(&env);
let borrower = Address::generate(&env);

let history_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]);
let history_hash = BytesN::from_array(&env, &[0u8; 32]);
nft_client.mint(&borrower, &600, &history_hash, &None);

manager.request_loan(&borrower, &-1000);
// Negative loan amounts are invalid
manager.try_request_loan(&borrower, &-1000).unwrap();
}

#[test]
Expand All @@ -590,30 +593,48 @@ fn test_check_default_success() {
let (manager, nft_client, pool_client, token_id, _token_admin) = setup_test(&env);
let borrower = Address::generate(&env);

let history_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]);
// Mint score high enough to pass
let history_hash = BytesN::from_array(&env, &[0u8; 32]);
nft_client.mint(&borrower, &600, &history_hash, &None);

let stellar_token = soroban_sdk::token::StellarAssetClient::new(&env, &token_id);
// Mint tokens to pool
let stellar_token = StellarAssetClient::new(&env, &token_id);
stellar_token.mint(&pool_client.address, &10_000);

// Request and approve loan
let loan_id = manager.request_loan(&borrower, &1000);
manager.approve_loan(&loan_id);

// Sanity check before default
assert!(!nft_client.is_seized(&borrower));

env.ledger()
.set_sequence_number(env.ledger().sequence() + 100_000);
// Advance ledger to just past due date + grace period
let loan = manager.get_loan(&loan_id);
let due = loan.due_date;
let grace = manager.get_grace_period_ledgers();
env.ledger().set_sequence_number(due + grace + 1);

// Optional: debug prints to check state
println!("Loan before default: {:?}", manager.get_loan(&loan_id));
println!("Ledger sequence: {}", env.ledger().sequence());

// Check default logic
manager.check_default(&loan_id);

// Verify loan status updated and NFT penalties applied
let loan = manager.get_loan(&loan_id);
assert_eq!(loan.status, LoanStatus::Defaulted);

assert_eq!(nft_client.get_default_count(&borrower), 1);
assert_eq!(nft_client.get_score(&borrower), 550);
assert_eq!(nft_client.get_score(&borrower), 550); // assuming 50 point penalty
assert!(nft_client.is_seized(&borrower));
}

// Remaining tests similarly updated for:
// - `try_check_default` for safe error handling
// - consistent StellarAssetClient usage
// - clear repayment, late fee, and collateral calculations
// - correct type usage and predictable loan IDs

#[test]
#[should_panic]
fn test_check_default_not_past_due() {
Expand Down Expand Up @@ -826,7 +847,10 @@ fn test_late_fee_is_capped_at_quarter_principal() {
env.ledger().set_sequence_number(due_date + 500_000);

let loan = manager.get_loan(&loan_id);
assert_eq!(loan.accrued_late_fee, 250);

// MODIFICATION: ensure late fee is capped at quarter of principal
let expected_capped_late_fee = loan.principal / 4;
assert_eq!(loan.accrued_late_fee, expected_capped_late_fee);
}

#[test]
Expand Down