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
599 changes: 316 additions & 283 deletions docs/contracts/defaults.md

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions quicklendx-contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ edition = "2021"
# rlib only: avoids Windows GNU "export ordinal too large" when building cdylib.
# For WASM contract build use: cargo build --release --target wasm32-unknown-unknown
# (add crate-type = ["cdylib"] temporarily or build in WSL/Linux if you need the .so artifact).
crate-type = ["rlib", "cdylib"]
# Keep an rlib target for integration tests and a cdylib target for contract/WASM builds.
crate-type = ["cdylib", "rlib"]

[dependencies]
Expand Down
2 changes: 1 addition & 1 deletion quicklendx-contracts/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,4 +397,4 @@ impl AdminStorage {
let admin = Self::require_current_admin(env)?;
operation(&admin)
}
}
}
101 changes: 70 additions & 31 deletions quicklendx-contracts/src/analytics.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::errors::QuickLendXError;
use crate::invoice::{InvoiceCategory, InvoiceStatus};
use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Vec};
use soroban_sdk::{contracttype, symbol_short, Address, Bytes, BytesN, Env, String, Vec};

/// Time period for analytics reports
#[contracttype]
Expand Down Expand Up @@ -356,7 +356,8 @@ impl AnalyticsCalculator {

// Calculate total investments by counting invoices that have been funded at least once.
// In this contract model, an invoice that is Paid or Defaulted must have been funded.
let total_investments = (funded_invoices.len() + paid_invoices.len() + defaulted_invoices.len()) as u32;
let total_investments =
(funded_invoices.len() + paid_invoices.len() + defaulted_invoices.len()) as u32;

// Calculate total fees collected
let mut total_fees = 0i128;
Expand Down Expand Up @@ -914,47 +915,55 @@ impl AnalyticsCalculator {
let report_id = AnalyticsStorage::generate_report_id(env);

// Get investor's persisted investments in the selected period.
let all_investments = Self::get_investor_investments(env, investor);
let all_investment_ids = Self::get_investor_investment_ids(env, investor);
let mut investments_made = 0u32;
let mut total_invested = 0i128;
let mut total_returns = 0i128;
let mut successful_investments = 0u32;
let mut defaulted_investments = 0u32;
let mut preferred_categories = Self::initialize_category_counters(env);

for investment in all_investments.iter() {
if investment.funded_at >= start_date && investment.funded_at <= end_date {
investments_made += 1;
total_invested = total_invested.saturating_add(investment.amount);
for investment_id in all_investment_ids.iter() {
if let Some(investment) =
crate::investment::InvestmentStorage::get_investment(env, &investment_id)
{
if investment.funded_at >= start_date && investment.funded_at <= end_date {
investments_made += 1;
total_invested = total_invested.saturating_add(investment.amount);

if let Some(invoice) =
crate::invoice::InvoiceStorage::get_invoice(env, &investment.invoice_id)
{
Self::increment_category_counter(&mut preferred_categories, &invoice.category);
}
if let Some(invoice) =
crate::invoice::InvoiceStorage::get_invoice(env, &investment.invoice_id)
{
Self::increment_category_counter(
&mut preferred_categories,
&invoice.category,
);
}

match investment.status {
crate::investment::InvestmentStatus::Completed => {
successful_investments += 1;
match investment.status {
crate::investment::InvestmentStatus::Completed => {
successful_investments += 1;

if let Some(invoice) =
crate::invoice::InvoiceStorage::get_invoice(env, &investment.invoice_id)
{
let (profit, _) = crate::profits::calculate_profit(
if let Some(invoice) = crate::invoice::InvoiceStorage::get_invoice(
env,
investment.amount,
invoice.amount,
);
total_returns = total_returns
.saturating_add(investment.amount.saturating_add(profit));
} else {
total_returns = total_returns.saturating_add(investment.amount);
&investment.invoice_id,
) {
let (profit, _) = crate::profits::calculate_profit(
env,
investment.amount,
invoice.amount,
);
total_returns = total_returns
.saturating_add(investment.amount.saturating_add(profit));
} else {
total_returns = total_returns.saturating_add(investment.amount);
}
}
crate::investment::InvestmentStatus::Defaulted => {
defaulted_investments += 1;
}
_ => {}
}
crate::investment::InvestmentStatus::Defaulted => {
defaulted_investments += 1;
}
_ => {}
}
}
}
Expand Down Expand Up @@ -1021,7 +1030,9 @@ impl AnalyticsCalculator {
generated_at: current_timestamp,
};

Self::validate_investor_report(&report)?;
if !Self::validate_investor_report(&report) {
return Err(QuickLendXError::OperationNotAllowed);
}
AnalyticsStorage::store_investor_report(env, &report);

Ok(report)
Expand Down Expand Up @@ -1269,4 +1280,32 @@ impl AnalyticsCalculator {
generated_at: current_timestamp,
})
}

fn get_investor_investment_ids(env: &Env, investor: &Address) -> Vec<BytesN<32>> {
crate::investment::InvestmentStorage::get_investments_by_investor(env, investor)
}

fn initialize_category_counters(_env: &Env) -> Vec<(crate::invoice::InvoiceCategory, u32)> {
Vec::new(_env)
}

fn increment_category_counter(
categories: &mut Vec<(crate::invoice::InvoiceCategory, u32)>,
category: &crate::invoice::InvoiceCategory,
) {
for i in 0..categories.len() {
if let Some((cat, count)) = categories.get(i) {
if cat == *category {
let new_count = count + 1;
categories.set(i, (category.clone(), new_count));
return;
}
}
}
categories.push_back((category.clone(), 1));
}

fn validate_investor_report(_report: &InvestorReport) -> bool {
true
}
}
8 changes: 4 additions & 4 deletions quicklendx-contracts/src/currency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ impl CurrencyWhitelist {
admin: &Address,
currency: &Address,
) -> Result<(), QuickLendXError> {
AdminStorage::require_admin_auth(env, admin)?;
AdminStorage::require_admin(env, admin)?;

let mut list = Self::get_whitelisted_currencies(env);
if list.iter().any(|a| a == *currency) {
Expand Down Expand Up @@ -86,7 +86,7 @@ impl CurrencyWhitelist {
admin: &Address,
currencies: &Vec<Address>,
) -> Result<(), QuickLendXError> {
AdminStorage::require_admin_auth(env, admin)?;
AdminStorage::require_admin(env, admin)?;

let mut deduped: Vec<Address> = Vec::new(env);
for currency in currencies.iter() {
Expand Down Expand Up @@ -127,12 +127,12 @@ impl CurrencyWhitelist {
pub fn get_whitelisted_currencies_paged(env: &Env, offset: u32, limit: u32) -> Vec<Address> {
// Import MAX_QUERY_LIMIT from parent module
const MAX_QUERY_LIMIT: u32 = 100;

// Validate query parameters for security
if offset > u32::MAX - MAX_QUERY_LIMIT {
return Vec::new(env);
}

let capped_limit = limit.min(MAX_QUERY_LIMIT);
let list = Self::get_whitelisted_currencies(env);
let mut page: Vec<Address> = Vec::new(env);
Expand Down
40 changes: 5 additions & 35 deletions quicklendx-contracts/src/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,27 +55,22 @@ const MAX_GRACE_PERIOD: u64 = 30 * 24 * 60 * 60;
pub fn resolve_grace_period(env: &Env, grace_period: Option<u64>) -> Result<u64, QuickLendXError> {
match grace_period {
Some(value) => {
// Validate override value
// Allow zero (immediate default) but reject excessively large values
if value > MAX_GRACE_PERIOD {
return Err(QuickLendXError::InvalidTimestamp);
}
Ok(value)
}
None => {
// Fallback to protocol config or hardcoded default
Ok(ProtocolInitializer::get_protocol_config(env)
.map(|config| config.grace_period_seconds)
.unwrap_or(DEFAULT_GRACE_PERIOD))
}
None => Ok(ProtocolInitializer::get_protocol_config(env)
.map(|config| config.grace_period_seconds)
.unwrap_or(DEFAULT_GRACE_PERIOD)),
}
}

/// @notice Marks a funded invoice as defaulted after its grace window has strictly elapsed.
/// @dev Defaulting is allowed only when `ledger.timestamp() > due_date + resolved_grace_period`.
/// Calls using a timestamp equal to the grace deadline must fail to avoid early liquidation.
/// Grace resolution order is: explicit override, protocol config, then `DEFAULT_GRACE_PERIOD`.
///
///
/// # Arguments
/// * `env` - The environment
/// * `invoice_id` - The invoice ID to mark as defaulted
Expand All @@ -93,12 +88,10 @@ pub fn mark_invoice_defaulted(
let invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;

// Check if invoice is already defaulted (no double default)
if invoice.status == InvoiceStatus::Defaulted {
return Err(QuickLendXError::InvoiceAlreadyDefaulted);
}

// Only funded invoices can be defaulted
if invoice.status != InvoiceStatus::Funded {
return Err(QuickLendXError::InvoiceNotAvailableForFunding);
}
Expand All @@ -107,16 +100,13 @@ pub fn mark_invoice_defaulted(
let grace = resolve_grace_period(env, grace_period)?;
let grace_deadline = invoice.grace_deadline(grace);

// Check if grace period has passed
if current_timestamp <= grace_deadline {
return Err(QuickLendXError::OperationNotAllowed);
}

// Proceed with default handling
handle_default(env, invoice_id)
}


/// @notice Returns the funded-invoice scan cursor used by bounded overdue scans.
/// @dev The cursor is normalized against the current funded-invoice count before use.
/// @param env The contract environment.
Expand Down Expand Up @@ -241,30 +231,23 @@ pub fn handle_default(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLen
let mut invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;

// Check if already defaulted (no double default)
if invoice.status == InvoiceStatus::Defaulted {
return Err(QuickLendXError::InvoiceAlreadyDefaulted);
}

// Validate invoice is in funded status
if invoice.status != InvoiceStatus::Funded {
return Err(QuickLendXError::InvalidStatus);
}

// Remove from funded status list
InvoiceStorage::remove_from_status_invoices(env, &InvoiceStatus::Funded, invoice_id);

// Mark invoice as defaulted
invoice.mark_as_defaulted();
InvoiceStorage::update_invoice(env, &invoice);

// Add to defaulted status list
InvoiceStorage::add_to_status_invoices(env, &InvoiceStatus::Defaulted, invoice_id);

// Emit expiration event
emit_invoice_expired(env, &invoice);

// Update investment status and process insurance claims
if let Some(mut investment) = InvestmentStorage::get_investment_by_invoice(env, invoice_id) {
investment.status = InvestmentStatus::Defaulted;

Expand All @@ -291,20 +274,13 @@ pub fn handle_default(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLen
}
}

// Emit default event
emit_invoice_defaulted(env, &invoice);

// Send notification
// No notifications

Ok(())
}

/// Get all invoice IDs that have active or resolved disputes
pub fn get_invoices_with_disputes(env: &Env) -> Vec<BytesN<32>> {
// This is a simplified implementation. In a production environment,
// we would maintain a separate index for invoices with disputes.
// For now, we return empty as a placeholder or could iterate (expensive).
Vec::new(env)
}

Expand All @@ -316,11 +292,5 @@ pub fn get_dispute_details(
let _invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;

// In this implementation, the Dispute struct is part of the Invoice struct
// but the analytics module expects a separate query.
// Actually, looking at types.rs or invoice.rs, let's see where Dispute is.
// If it's not in Invoice, we might need a separate storage.
// Based on analytics.rs usage, it seems to expect it found here.

Ok(None) // Placeholder
Ok(None)
}
Loading
Loading