forked from QuickLendX/quicklendx-protocol
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdefaults.rs
More file actions
433 lines (372 loc) · 16.4 KB
/
Copy pathdefaults.rs
File metadata and controls
433 lines (372 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
use crate::errors::QuickLendXError;
use crate::events::{emit_insurance_claimed, emit_invoice_defaulted, emit_invoice_expired};
use crate::init::ProtocolInitializer;
use crate::payments::{EscrowStatus, EscrowStorage};
use crate::storage::{InvestmentStorage, InvoiceStorage};
use crate::types::{InvestmentStatus, Invoice, InvoiceStatus};
use soroban_sdk::{contracttype, symbol_short, BytesN, Env, Vec};
/// Default grace period in seconds (7 days)
pub const DEFAULT_GRACE_PERIOD: u64 = 7 * 24 * 60 * 60;
/// Default number of funded invoices processed per overdue scan call.
pub const DEFAULT_OVERDUE_SCAN_BATCH_LIMIT: u32 = 25;
/// Hard cap for caller-provided overdue scan limits.
pub const MAX_OVERDUE_SCAN_BATCH_LIMIT: u32 = 100;
const OVERDUE_SCAN_CURSOR_KEY: soroban_sdk::Symbol = symbol_short!("ovd_scan");
/// Storage key for default transition guards.
/// Format: (symbol_short!("def_guard"), invoice_id) -> bool
const DEFAULT_TRANSITION_GUARD_KEY: soroban_sdk::Symbol = symbol_short!("def_guard");
/// Transition guard to ensure default transitions are atomic and idempotent.
/// Tracks whether a default transition has been initiated for a specific invoice.
///
/// **Finality**: Once a default transition is guarded and triggered, the invoice reaches
/// a terminal `Defaulted` state. It cannot be subsequently funded, settled, or have payments
/// recorded. Insurance claims are processed exactly once during this atomic transition.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransitionGuard {
/// Whether the default transition has been triggered
pub triggered: bool,
}
/// @notice Checks if a default transition guard exists for the given invoice.
/// @dev Returns true if the guard is set (transition already attempted), false otherwise.
/// @param env The contract environment.
/// @param invoice_id The invoice ID to check.
/// @return true if default transition has been guarded, false otherwise.
fn is_default_transition_guarded(env: &Env, invoice_id: &BytesN<32>) -> bool {
env.storage()
.persistent()
.has(&(DEFAULT_TRANSITION_GUARD_KEY, invoice_id))
}
/// @notice Atomically checks and sets the default transition guard.
/// @dev This ensures that only one default transition can be initiated per invoice.
/// If the guard is already set, returns DuplicateDefaultTransition error.
/// Otherwise, sets the guard and returns Ok(()).
/// @param env The contract environment.
/// @param invoice_id The invoice ID to guard.
/// @return Ok(()) if guard was successfully set, Err(DuplicateDefaultTransition) if already guarded.
fn check_and_set_default_guard(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> {
let key = (DEFAULT_TRANSITION_GUARD_KEY, invoice_id);
// Check if guard is already set
if env.storage().persistent().has(&key) {
return Err(QuickLendXError::DuplicateDefaultTransition);
}
// Set the guard atomically
env.storage().persistent().set(&key, &true);
Ok(())
}
/// Result metadata returned by the bounded overdue invoice scanner.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OverdueScanResult {
pub overdue_count: u32,
pub scanned_count: u32,
pub total_funded: u32,
pub next_cursor: u32,
}
/// Maximum allowed grace period in seconds (30 days)
/// This prevents excessively long grace periods that could lock funds indefinitely
const MAX_GRACE_PERIOD: u64 = 30 * 24 * 60 * 60;
/// Resolve grace period using per-call override, protocol config, or default.
///
/// # Fallback Resolution Order
/// 1. If `grace_period` is provided and valid -> use it (after validation)
/// 2. If `grace_period` is None -> try protocol config
/// 3. If protocol config not available -> use hardcoded DEFAULT_GRACE_PERIOD
///
/// # Validation Rules
/// - Override values must be <= MAX_GRACE_PERIOD (30 days)
/// - Invalid overrides are rejected with QuickLendXError::InvalidTimestamp
/// - Zero grace period is allowed (immediate default after due date)
///
/// # Security Considerations
/// - Prevents denial-of-service via extremely large grace periods
/// - Ensures deterministic behavior across all code paths
/// - Maintains consistency with protocol-limits configuration
///
/// # Arguments
/// * `env` - The Soroban environment
/// * `grace_period` - Optional grace period override in seconds
///
/// # Returns
/// * `Ok(u64)` - Resolved grace period value
/// * `Err(QuickLendXError::InvalidTimestamp)` - If override exceeds maximum allowed value
pub fn resolve_grace_period(env: &Env, grace_period: Option<u64>) -> Result<u64, QuickLendXError> {
match grace_period {
Some(value) => {
if value > MAX_GRACE_PERIOD {
return Err(QuickLendXError::InvalidTimestamp);
}
Ok(value)
}
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
/// * `grace_period` - Optional grace period in seconds. If `None`, uses protocol config or
/// `DEFAULT_GRACE_PERIOD` when not configured.
///
/// # Returns
/// * `Ok(())` if the invoice was successfully marked as defaulted
/// * `Err(QuickLendXError)` if the operation fails
///
/// # Finality Matrix
/// The defaulting decision table for invoice status, settlement finalization, and escrow status
/// is documented in `docs/default-finality-matrix.md` and enforced by
/// `test_default_finality_matrix.rs`.
pub fn mark_invoice_defaulted(
env: &Env,
invoice_id: &BytesN<32>,
grace_period: Option<u64>,
) -> Result<(), QuickLendXError> {
let invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;
if is_default_transition_guarded(env, invoice_id) {
return Err(QuickLendXError::DuplicateDefaultTransition);
}
if invoice.status == InvoiceStatus::Defaulted {
return Err(QuickLendXError::InvoiceAlreadyDefaulted);
}
if invoice.status != InvoiceStatus::Funded {
return Err(QuickLendXError::InvoiceNotAvailableForFunding);
}
ensure_default_transition_open(env, invoice_id)?;
let current_timestamp = env.ledger().timestamp();
let grace = resolve_grace_period(env, grace_period)?;
let grace_deadline = invoice.grace_deadline(grace);
if current_timestamp <= grace_deadline {
return Err(QuickLendXError::OperationNotAllowed);
}
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.
/// @return Zero-based index of the next funded invoice to inspect.
pub fn get_overdue_scan_cursor(env: &Env) -> u32 {
env.storage()
.instance()
.get(&OVERDUE_SCAN_CURSOR_KEY)
.unwrap_or(0)
}
/// @notice Returns the batch size used when callers do not provide an explicit scan limit.
/// @return Default funded-invoice batch size for overdue scanning.
pub fn default_overdue_scan_batch_limit() -> u32 {
DEFAULT_OVERDUE_SCAN_BATCH_LIMIT
}
/// @notice Returns the maximum funded-invoice batch size accepted by bounded overdue scans.
/// @return Hard cap applied to caller-provided scan limits.
pub fn max_overdue_scan_batch_limit() -> u32 {
MAX_OVERDUE_SCAN_BATCH_LIMIT
}
fn set_overdue_scan_cursor(env: &Env, cursor: u32) {
env.storage()
.instance()
.set(&OVERDUE_SCAN_CURSOR_KEY, &cursor);
}
fn normalize_cursor(cursor: u32, funded_count: u32) -> u32 {
if funded_count == 0 || cursor >= funded_count {
0
} else {
cursor
}
}
/// Resolve the requested scan batch size, clamping to a safe per-call window.
/// This prevents callers from forcing an unbounded scan workload in a single contract execution.
fn resolve_scan_limit(limit: Option<u32>) -> u32 {
limit
.unwrap_or(DEFAULT_OVERDUE_SCAN_BATCH_LIMIT)
.clamp(1, MAX_OVERDUE_SCAN_BATCH_LIMIT)
}
#[cfg(test)]
mod scan_limit_tests {
use super::{resolve_scan_limit, MAX_OVERDUE_SCAN_BATCH_LIMIT};
#[test]
fn zero_scan_limit_is_clamped_to_one() {
assert_eq!(resolve_scan_limit(Some(0)), 1);
}
#[test]
fn maximum_scan_limit_is_accepted() {
assert_eq!(
resolve_scan_limit(Some(MAX_OVERDUE_SCAN_BATCH_LIMIT)),
MAX_OVERDUE_SCAN_BATCH_LIMIT
);
}
#[test]
fn scan_limit_above_maximum_is_clamped() {
assert_eq!(
resolve_scan_limit(Some(MAX_OVERDUE_SCAN_BATCH_LIMIT + 1)),
MAX_OVERDUE_SCAN_BATCH_LIMIT
);
}
}
/// @notice Scans funded invoices in a deterministic bounded window for overdue/default handling.
/// @dev Uses a rotating cursor stored in instance storage so repeated calls eventually inspect
/// the full funded set without any single call walking every invoice. The function reads a
/// snapshot of the funded index once, then processes at most `limit` entries from that snapshot.
/// @param env The contract environment.
/// @param grace_period Grace period in seconds used to determine default eligibility.
/// @param limit Optional funded-invoice batch size. Values are clamped to `1..=100`.
/// @return Scan result containing overdue count, scanned count, funded snapshot size, and next cursor.
/// @security Bounded loops protect against excessive per-call work. Callers that need full coverage
/// must invoke the scan repeatedly until `next_cursor` wraps to `0`.
/// @security The scan window is always capped by `max_overdue_scan_batch_limit` and never exceeds
/// the current funded snapshot size, preventing any single invocation from iterating
/// an unbounded number of invoices.
pub fn scan_funded_invoice_expirations(
env: &Env,
grace_period: u64,
limit: Option<u32>,
) -> Result<OverdueScanResult, QuickLendXError> {
let funded_invoices = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Funded);
let total_funded = funded_invoices.len();
if total_funded == 0 {
set_overdue_scan_cursor(env, 0);
return Ok(OverdueScanResult {
overdue_count: 0,
scanned_count: 0,
total_funded: 0,
next_cursor: 0,
});
}
// Bounded scan window: clamp the requested limit, then cap to the funded snapshot size.
let scan_limit = resolve_scan_limit(limit).min(total_funded);
let current_timestamp = env.ledger().timestamp();
let mut cursor = normalize_cursor(get_overdue_scan_cursor(env), total_funded);
let mut overdue_count = 0u32;
let mut scanned_count = 0u32;
while scanned_count < scan_limit {
if let Some(invoice_id) = funded_invoices.get(cursor) {
if let Some(invoice) = InvoiceStorage::get_invoice(env, &invoice_id) {
if invoice.is_overdue(current_timestamp) {
overdue_count = overdue_count.saturating_add(1);
let _ = crate::notifications::NotificationSystem::notify_payment_overdue(
env, &invoice,
);
}
if current_timestamp > invoice.grace_deadline(grace_period) {
let _ = invoice.check_and_handle_expiration(env, grace_period)?;
}
}
}
scanned_count = scanned_count.saturating_add(1);
cursor = if cursor + 1 >= total_funded {
0
} else {
cursor + 1
};
}
let next_cursor = if scan_limit >= total_funded {
0
} else {
cursor
};
set_overdue_scan_cursor(env, next_cursor);
Ok(OverdueScanResult {
overdue_count,
scanned_count,
total_funded,
next_cursor,
})
}
/// @notice Applies the default transition after all time and status checks have passed.
/// @dev This helper does not re-check the grace-period cutoff and must only be reached from
/// validated call sites such as `mark_invoice_defaulted` or `check_and_handle_expiration`.
/// The transition guard ensures atomicity and idempotency of default operations.
/// @security The guard prevents race conditions and duplicate side effects (analytics, state initialization).
/// @security Settlement finalization and non-held escrow states block defaulting to prevent
/// double-finality or double-payout drift. See `docs/default-finality-matrix.md`.
pub fn handle_default(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> {
let mut invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;
if invoice.status == InvoiceStatus::Defaulted {
return Err(QuickLendXError::InvoiceAlreadyDefaulted);
}
if invoice.status != InvoiceStatus::Funded {
return Err(QuickLendXError::InvalidStatus);
}
ensure_default_transition_open(env, invoice_id)?;
// Atomically check and set the transition guard only after all finality checks pass.
// This avoids poisoning future legitimate retries on invoices that were never eligible
// for default because another terminal path already completed first.
check_and_set_default_guard(env, invoice_id)?;
InvoiceStorage::remove_from_status_invoices(env, InvoiceStatus::Funded, invoice_id);
invoice.mark_as_defaulted();
InvoiceStorage::update_invoice(env, &invoice);
InvoiceStorage::add_to_status_invoices(env, InvoiceStatus::Defaulted, invoice_id);
let history_key = crate::storage::StorageKeys::business_default_history(&invoice.business);
let history_count: u32 = env.storage().persistent().get(&history_key).unwrap_or(0);
env.storage()
.persistent()
.set(&history_key, &history_count.saturating_add(1));
crate::storage::bump_persistent(env, &history_key);
emit_invoice_expired(env, &invoice);
if let Some(mut investment) = InvestmentStorage::get_investment_by_invoice(env, invoice_id) {
require_active_insurance_at_settlement(env, &invoice)?;
investment.status = InvestmentStatus::Defaulted;
let claim_details = investment.process_all_insurance_claims(env);
InvestmentStorage::update_investment(env, &investment);
for (provider, coverage_amount) in claim_details.iter() {
if coverage_amount > 0 {
emit_insurance_claimed(
env,
&investment.investment_id,
&investment.invoice_id,
&provider,
coverage_amount,
);
}
}
}
emit_invoice_defaulted(env, &invoice);
// Lifecycle trigger: emits `NotificationType::InvoiceDefaulted` to business
// and investor after the default transition is fully persisted.
let _ = crate::notifications::NotificationSystem::notify_invoice_defaulted(env, &invoice);
Ok(())
}
fn ensure_default_transition_open(
env: &Env,
invoice_id: &BytesN<32>,
) -> Result<(), QuickLendXError> {
if crate::settlement::is_invoice_finalized(env, invoice_id)? {
return Err(QuickLendXError::InvalidStatus);
}
if let Some(escrow) = EscrowStorage::get_escrow_by_invoice(env, invoice_id) {
if escrow.status != EscrowStatus::Held {
return Err(QuickLendXError::InvalidStatus);
}
}
Ok(())
}
/// Get all invoice IDs that have active or resolved disputes
pub fn get_invoices_with_disputes(env: &Env) -> Vec<BytesN<32>> {
Vec::new(env)
}
/// Get details for a dispute on a specific invoice
pub fn get_dispute_details(
env: &Env,
invoice_id: &BytesN<32>,
) -> Result<Option<crate::types::Dispute>, QuickLendXError> {
let _invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;
Ok(None)
}
pub fn require_active_insurance_at_settlement(
env: &Env,
invoice: &Invoice,
) -> Result<(), QuickLendXError> {
if let Some(investment) = InvestmentStorage::get_investment_by_invoice(env, &invoice.id) {
if !investment.insurance.is_empty() && !investment.has_active_insurance() {
return Err(QuickLendXError::InsuranceNotActive);
}
}
Ok(())
}