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
248 changes: 248 additions & 0 deletions contracts/loan/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,68 @@ impl LoanContract {
);
}

/// Marks a loan as defaulted if it is past due and has pending balance.
///
/// # Authorization
/// Anyone can call this function (community enforcement).
///
/// # Arguments
/// * `loan_id` - The ID of the loan to mark as defaulted
///
/// # Panics
/// - If loan does not exist
/// - If loan is not in `Approved` status
/// - If loan is not past due (`repayment_due` > current ledger timestamp)
/// - If loan has been fully repaid
///
/// # Events
/// Emits `loan_defaulted` with loan_id, borrower, and pending_amount.
pub fn mark_defaulted(env: Env, loan_id: u32) {
// 1. Obtener todos los préstamos
let mut loans: Vec<Loan> = env.storage().instance()
.get(&DataKey::Loans).unwrap_or_else(|| panic!("no loans found"));

// 2. Encontrar el índice del préstamo
let idx = Self::find_loan_idx(&loans, loan_id);
let mut loan = loans.get(idx).unwrap();

// 3. Validar que el préstamo está en estado Approved
if loan.status != LoanStatus::Approved {
panic!("loan must be in Approved status");
}

// 4. Validar que el préstamo está vencido
let current_timestamp = env.ledger().timestamp();
if current_timestamp <= loan.repayment_due {
panic!("loan is not past due");
}

// 5. Validar que hay saldo pendiente
let total_due = loan.amount + (loan.amount * loan.interest_bps as i128 / 10_000);
if loan.amount_repaid >= total_due {
panic!("loan has been fully repaid");
}

// 6. Calcular el monto pendiente
let pending_amount = total_due - loan.amount_repaid;

// 7. Actualizar el estado a Defaulted
loan.status = LoanStatus::Defaulted;

// 8. Guardar el préstamo actualizado
loans.set(idx, loan.clone());
env.storage().instance().set(&DataKey::Loans, &loans);

// 9. Emitir el evento
env.events().publish(
(Symbol::new(&env, "loan_defaulted"),),
(loan_id, loan.borrower, pending_amount),
);

// 10. Extender TTL del storage de instancia (100 ledgers)
env.storage().instance().extend_ttl(100, 100);
}

/// Get all loans.
pub fn get_loans(env: Env) -> Vec<Loan> {
env.storage().instance()
Expand Down Expand Up @@ -201,3 +263,189 @@ impl LoanContract {
panic!("loan not found");
}
}

/// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::testutils::{Address as _, Ledger};
use soroban_sdk::{token::Client as TokenClient, token::StellarAssetClient, Env};

fn setup() -> (Env, LoanContractClient<'static>, Address, Address, Address) {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, LoanContract);
let client = LoanContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let borrower = Address::generate(&env);
let token_admin = Address::generate(&env);
let asset = env.register_stellar_asset_contract_v2(token_admin.clone());
let asset_address = asset.address();

// Fund borrower
StellarAssetClient::new(&env, &asset_address)
.mint(&borrower, &100_000_0000000i128);

// Fund the contract itself (treasury) for disbursement
StellarAssetClient::new(&env, &asset_address)
.mint(&contract_id, &100_000_0000000i128);

// Initialize contract
client.initialize(&admin, &admin, &asset_address);

(env, client, admin, borrower, asset_address)
}

fn create_approved_loan(
env: &Env,
client: &LoanContractClient<'static>,
admin: &Address,
borrower: &Address,
) -> u32 {
// Request loan
let loan_id = client.request_loan(
borrower,
&10_000_0000000i128,
&String::from_str(env, "Test loan"),
&30, // 30 days
);

// Approve loan (disburses funds)
client.approve_loan(admin, &loan_id);

loan_id
}

#[test]
fn test_initialize() {
// setup() already initializes, so we just verify no panic
let (env, client, admin, _, asset) = setup();
// Verify contract is initialized by checking loans exist
let loans = client.get_loans();
assert_eq!(loans.len(), 0);
}

#[test]
#[should_panic(expected = "already initialized")]
fn test_double_initialize() {
let (env, client, admin, _, asset) = setup();
// Try to initialize again
client.initialize(&admin, &admin, &asset);
}

#[test]
fn test_request_loan() {
let (env, client, _, borrower, _) = setup();
let loan_id = client.request_loan(
&borrower,
&1_000_0000000i128,
&String::from_str(&env, "Test loan"),
&30,
);
assert_eq!(loan_id, 1);
}

#[test]
fn test_approve_loan() {
let (env, client, admin, borrower, _) = setup();
let loan_id = client.request_loan(
&borrower,
&10_000_0000000i128,
&String::from_str(&env, "Test loan"),
&30,
);
client.approve_loan(&admin, &loan_id);
let loan = client.get_loan(&loan_id);
assert_eq!(loan.status, LoanStatus::Approved);
}

#[test]
fn test_repay_loan() {
let (env, client, admin, borrower, _) = setup();
let loan_id = create_approved_loan(&env, &client, &admin, &borrower);

// Calculate total due (5% interest)
let principal = 10_000_0000000i128;
let interest = principal * 500 / 10_000;
let total_due = principal + interest;

// Repay loan
client.repay(&borrower, &loan_id, &total_due);

let loan = client.get_loan(&loan_id);
assert_eq!(loan.status, LoanStatus::Repaid);
}

// ── mark_defaulted tests ────────────────────────────────────────────────────

#[test]
fn test_mark_defaulted_success() {
let (env, client, admin, borrower, _) = setup();
let loan_id = create_approved_loan(&env, &client, &admin, &borrower);

// Advance time past repayment_due (30 days)
env.ledger().with_mut(|l| l.timestamp = 1_700_000_000);

// Mark as defaulted
client.mark_defaulted(&loan_id);

// Verify status
let loan = client.get_loan(&loan_id);
assert_eq!(loan.status, LoanStatus::Defaulted);
}

#[test]
#[should_panic(expected = "loan is not past due")]
fn test_mark_defaulted_not_past_due() {
let (env, client, admin, borrower, _) = setup();
let loan_id = create_approved_loan(&env, &client, &admin, &borrower);

// Don't advance time -> loan is not past due
client.mark_defaulted(&loan_id);
}

#[test]
#[should_panic(expected = "loan must be in Approved status")]
fn test_mark_defaulted_already_repaid() {
let (env, client, admin, borrower, _) = setup();
let loan_id = create_approved_loan(&env, &client, &admin, &borrower);

// Calculate total due (5% interest)
let principal = 10_000_0000000i128;
let interest = principal * 500 / 10_000;
let total_due = principal + interest;

// Repay the loan
client.repay(&borrower, &loan_id, &total_due);

// Advance time
env.ledger().with_mut(|l| l.timestamp = 1_700_000_000);

// Try to mark as defaulted (should fail because already repaid)
client.mark_defaulted(&loan_id);
}

#[test]
#[should_panic(expected = "loan not found")]
fn test_mark_defaulted_loan_not_found() {
let (env, client, _, _, _) = setup();
client.mark_defaulted(&999);
}

// ── edge cases ──────────────────────────────────────────────────────────────

#[test]
#[should_panic]
fn test_approve_nonexistent_loan() {
let (env, client, admin, _, _) = setup();
client.approve_loan(&admin, &999);
}

#[test]
#[should_panic]
fn test_repay_nonexistent_loan() {
let (env, client, _, borrower, _) = setup();
client.repay(&borrower, &999, &100);
}
}
Loading