Skip to content
Closed
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
3 changes: 2 additions & 1 deletion contracts/creditline-contract/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ pub enum CreditLineError {
InvalidInstallmentIndex = 23,
InstallmentAlreadyPaid = 24,
InvalidLoanStatus = 25,
NotInitialized = 26,
VendorAlreadyPaid = 26,
NotInitialized = 27,
}
8 changes: 8 additions & 0 deletions contracts/creditline-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const LOAN_CANCELLED: Symbol = symbol_short!("LOANCNCL");
const LOAN_LATE_FEE: Symbol = symbol_short!("LOANLTFE");
const LOAN_GRACE_PERIOD: Symbol = symbol_short!("LOANGRC");
const INSTALLMENT_PAID: Symbol = symbol_short!("INSTPAID");
const VENDOR_PAID: Symbol = symbol_short!("VENDPAID");

pub const LOANAPPROVED: &str = "LOANAPPROVED";

Expand Down Expand Up @@ -137,6 +138,13 @@ pub fn emit_installment_paid(
);
}

pub fn emit_vendor_paid(env: &Env, loan_id: u64, vendor: &Address, amount: i128) {
env.events().publish(
(VENDOR_PAID, loan_id),
(vendor, amount, env.ledger().timestamp()),
);
}

/// Emitted when a loan is past its due date but still within the grace period.
/// Signals that the borrower can still repay before a hard default is triggered.
pub fn emit_loan_in_grace_period(
Expand Down
59 changes: 55 additions & 4 deletions contracts/creditline-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ impl CreditLineContract {
repayment_schedule.clone(),
score,
LoanStatus::Active,
true,
loan_type,
)?;
loan.funded_at = env.ledger().timestamp();
Expand Down Expand Up @@ -136,6 +137,7 @@ impl CreditLineContract {
repayment_schedule.clone(),
score,
LoanStatus::Pending,
false,
loan_type,
)?;

Expand Down Expand Up @@ -344,6 +346,7 @@ impl CreditLineContract {
score: u32,
status: LoanStatus,
loan_type: LoanType,
vendor_paid: bool,
) -> Result<Loan, CreditLineError> {
Self::validate_guarantee(env, total_amount, guarantee_amount)?;
Self::validate_vendor(env, &vendor)?;
Expand Down Expand Up @@ -383,6 +386,7 @@ impl CreditLineContract {
remaining_balance,
repayment_schedule,
status,
vendor_paid,
loan_type,
created_at: env.ledger().timestamp(),
funded_at: 0,
Expand Down Expand Up @@ -564,18 +568,65 @@ impl CreditLineContract {
panic_with_error!(&env, CreditLineError::InvalidLoanStatus);
}

// 4. Transition to Active
loan.status = LoanStatus::Active;

// 5. Write back with TTL extension
Self::enter_non_reentrant(&env);
Self::pay_vendor_internal(&env, &mut loan)
.unwrap_or_else(|err| {
Self::exit_non_reentrant(&env);
panic_with_error!(&env, err)
});
storage::write_loan(&env, &loan);
Self::exit_non_reentrant(&env);

// 6. Emit event
events::emit_loan_approved(&env, loan_id);

loan
}

pub fn pay_vendor(env: Env, loan_id: u64) -> Result<(), CreditLineError> {
let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err));
admin.require_auth();

let mut loan = storage::read_loan(&env, loan_id)?;
if loan.status != LoanStatus::Pending {
return Err(CreditLineError::InvalidLoanStatus);
}

Self::enter_non_reentrant(&env);
let result = Self::pay_vendor_internal(&env, &mut loan);
if result.is_ok() {
storage::write_loan(&env, &loan);
}
Self::exit_non_reentrant(&env);
result
}

fn pay_vendor_internal(env: &Env, loan: &mut Loan) -> Result<(), CreditLineError> {
if loan.vendor_paid {
return Err(CreditLineError::VendorAlreadyPaid);
}

let liquidity_pool = storage::get_liquidity_pool(env)?
.ok_or(CreditLineError::InsufficientLiquidity)?;
let pool_contribution = safe_math::sub_i128(loan.total_amount, loan.guarantee_amount)?;

if pool_contribution > 0 {
let lp_client = LiquidityPoolContractClient::new(env, &liquidity_pool);
lp_client
.fund_loan(&env.current_contract_address(), &loan.vendor, &pool_contribution)
.unwrap_or_else(|err| panic_with_error!(env, err));
}

loan.vendor_paid = true;
loan.status = LoanStatus::Active;
loan.funded_at = env.ledger().timestamp();

storage::increase_user_active_debt(env, &loan.borrower, loan.remaining_balance)?;
events::emit_vendor_paid(&env, loan.loan_id, &loan.vendor, pool_contribution);

Ok(())
}

pub fn cancel_loan(env: Env, caller: Address, loan_id: u64) {
caller.require_auth();

Expand Down
75 changes: 73 additions & 2 deletions contracts/creditline-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use soroban_sdk::{
contract, contractimpl,
testutils::{Address as _, Events, Ledger},
token::Client as TokenClient,
Address, Env, String as SorobanString,
Address, Env, String as SorobanString, Symbol,
};
use vendor_registry_contract::VendorRegistryContract;

Expand Down Expand Up @@ -43,8 +43,21 @@ impl MockReputation {
#[contract]
pub struct MockLiquidityPool;

const MOCK_LP_TOKEN_KEY: Symbol = Symbol::short("LP_TOKEN");

#[contractimpl]
impl MockLiquidityPool {
pub fn set_token(env: Env, token: Address) {
env.storage().instance().set(&MOCK_LP_TOKEN_KEY, &token);
}

fn get_token(env: &Env) -> Address {
env.storage()
.instance()
.get(&MOCK_LP_TOKEN_KEY)
.unwrap_or_else(|| panic!("Liquidity pool token not configured"))
}

pub fn get_pool_stats(_env: Env) -> PoolStats {
PoolStats {
total_liquidity: 1_000_000,
Expand All @@ -55,7 +68,16 @@ impl MockLiquidityPool {
}
}

pub fn fund_loan(_env: Env, _creditline: Address, _vendor: Address, _amount: i128) {}
pub fn set_token_address(env: Env, token: Address) {
env.storage().instance().set(&MOCK_LP_TOKEN_KEY, &token);
}

pub fn fund_loan(env: Env, creditline: Address, vendor: Address, amount: i128) {
creditline.require_auth();
let token_address = Self::get_token(&env);
let token_client = TokenClient::new(&env, &token_address);
token_client.transfer(&env.current_contract_address(), &vendor, &amount);
}

pub fn receive_repayment(_env: Env, _from: Address, _amount: i128, _fee: i128) {}

Expand Down Expand Up @@ -124,6 +146,11 @@ impl TestCtx {
let token_id = env
.register_stellar_asset_contract_v2(token_admin.clone())
.address();

// Seed the liquidity pool so it can fund approved loans in tests.
let asset_client = StellarAssetClient::new(&env, &token_id);
asset_client.mint(&lp_id, &1_000_000);

client.initialize(&admin, &rep_id, &vendor_registry_id, &lp_id, &token_id);

TestCtx {
Expand Down Expand Up @@ -2888,12 +2915,56 @@ fn test_approve_loan_success() {
let loan_id = t.create_default_request(&user, &vendor);
let pending = t.client.get_loan(&loan_id);
assert_eq!(pending.status, LoanStatus::Pending);
assert!(!pending.vendor_paid);

let approved = t.client.approve_loan(&loan_id);
assert_eq!(approved.status, LoanStatus::Active);
assert!(approved.vendor_paid);

let stored = t.client.get_loan(&loan_id);
assert_eq!(stored.status, LoanStatus::Active);
assert!(stored.vendor_paid);

let balance = t.balance(&vendor);
let expected_payment = pending.total_amount - pending.guarantee_amount;
assert_eq!(balance, expected_payment);
}

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

let loan_id = t.create_default_request(&user, &vendor);
let pending = t.client.get_loan(&loan_id);
assert_eq!(pending.status, LoanStatus::Pending);
assert!(!pending.vendor_paid);

let result = t.client.pay_vendor(&loan_id);
assert!(result.is_ok());

let stored = t.client.get_loan(&loan_id);
assert_eq!(stored.status, LoanStatus::Active);
assert!(stored.vendor_paid);

let balance = t.balance(&vendor);
let expected_payment = pending.total_amount - pending.guarantee_amount;
assert_eq!(balance, expected_payment);
}

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

let loan_id = t.create_default_request(&user, &vendor);
assert!(t.client.pay_vendor(&loan_id).is_ok());

let second = t.client.pay_vendor(&loan_id);
assert!(second.is_err());
assert_eq!(second.unwrap_err(), CreditLineError::VendorAlreadyPaid);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions contracts/creditline-contract/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub struct Loan {
pub remaining_balance: i128,
pub repayment_schedule: soroban_sdk::Vec<RepaymentInstallment>,
pub status: LoanStatus,
pub vendor_paid: bool,
pub loan_type: LoanType,
pub created_at: u64, // Unix timestamp
pub funded_at: u64, // 0 means not funded yet
Expand Down
Loading