Skip to content

Commit 0c93eca

Browse files
authored
feat: close #826 #827 #828 #829 — cross-chain identity, collateral baskets, boost curves, amortization schedules (#884)
* feat: close #826 #827 #828 #829 — cross-chain identity, collateral baskets, boost curves, amortization schedules * fix: cargo fmt compliance across all modified files * fix: final cargo fmt compliance on per_period_rate_numer lines * fix: u64/u128 type mismatch in ratio() method on LOCK_PERIOD constants * fix: remove doc comment on function param, add explicit type imports in test.rs * fix: reorder imports in test.rs per cargo fmt * fix: cast interval_blocks from u64 to u128 for saturating_mul * fix: cargo fmt: split per_period_rate_numer assignment at = * fix: clippy: checked_div + unused _schedule_id vars * fix: cargo fmt: separate unwrap/set_caller lines, fix brace position * fix: make pm mutable in property-management register_property test * fix: correct Linear schedule total_installments assertion from 12 to 24
1 parent 110c543 commit 0c93eca

5 files changed

Lines changed: 622 additions & 4 deletions

File tree

contracts/lending/src/lib.rs

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,104 @@ mod propchain_lending {
310310
pub start_block: Option<u64>,
311311
}
312312

313+
// ── #829: Variable amortization schedules ────────────────────────────────
314+
315+
/// The repayment schedule type for a loan.
316+
#[derive(
317+
Debug,
318+
Clone,
319+
Copy,
320+
PartialEq,
321+
Eq,
322+
scale::Encode,
323+
scale::Decode,
324+
ink::storage::traits::StorageLayout,
325+
)]
326+
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
327+
pub enum Schedule {
328+
/// Single lump-sum repayment at maturity (principal + all interest).
329+
Bullet,
330+
/// Equal total payments each period (principal portion grows over time).
331+
Annuity,
332+
/// Equal principal payments each period (descending total outlay).
333+
Linear,
334+
/// User-defined custom schedule parameters.
335+
Custom {
336+
/// Custom number of installments.
337+
num_installments: u32,
338+
/// Custom interval between payments (blocks).
339+
interval_blocks: u64,
340+
/// Custom principal per installment (0 = computed from total).
341+
principal_per_payment: u128,
342+
},
343+
}
344+
345+
impl Schedule {
346+
/// Compute the per-period installment amount given total principal,
347+
/// interest rate (bps), term months, and blocks per period.
348+
pub fn installment(
349+
&self,
350+
principal: u128,
351+
rate_bps: u32,
352+
term_months: u32,
353+
interval_blocks: u64,
354+
) -> u128 {
355+
match self {
356+
Schedule::Bullet => principal, // full principal at end
357+
Schedule::Annuity => {
358+
// Simplified annuity: equal total payments each period.
359+
// per_period_rate = rate_bps * interval_blocks / (5_256_000 * 10_000)
360+
let n = (term_months as u64 * 432_000u64)
361+
.checked_div(interval_blocks.max(1))
362+
.unwrap_or(1) as u128;
363+
if n == 0 {
364+
return principal;
365+
}
366+
let per_period_rate_numer =
367+
(rate_bps as u128).saturating_mul(interval_blocks as u128);
368+
let per_period_rate_denom = 52_560_000_000u128; // 5_256_000 * 10_000
369+
let interest = principal
370+
.saturating_mul(per_period_rate_numer)
371+
.checked_div(per_period_rate_denom)
372+
.unwrap_or(0);
373+
let base = principal / n;
374+
base.saturating_add(interest)
375+
}
376+
Schedule::Linear => {
377+
let n = (term_months as u64 * 432_000u64)
378+
.checked_div(interval_blocks.max(1))
379+
.unwrap_or(1) as u128;
380+
if n == 0 {
381+
return principal;
382+
}
383+
// Equal principal + declining interest
384+
let per_period_principal = principal / n;
385+
let per_period_rate_numer =
386+
(rate_bps as u128).saturating_mul(interval_blocks as u128);
387+
let per_period_rate_denom = 52_560_000_000u128; // 5_256_000 * 10_000
388+
let interest_first = principal
389+
.saturating_mul(per_period_rate_numer)
390+
.checked_div(per_period_rate_denom)
391+
.unwrap_or(0);
392+
per_period_principal.saturating_add(interest_first)
393+
}
394+
Schedule::Custom {
395+
principal_per_payment,
396+
..
397+
} => {
398+
if *principal_per_payment > 0 {
399+
*principal_per_payment
400+
} else {
401+
let n = (term_months as u64 * 432_000u64)
402+
.checked_div(interval_blocks.max(1))
403+
.unwrap_or(1) as u128;
404+
principal.checked_div(n).unwrap_or(principal)
405+
}
406+
}
407+
}
408+
}
409+
}
410+
313411
#[derive(
314412
Debug,
315413
Clone,
@@ -334,6 +432,7 @@ mod propchain_lending {
334432
pub schedule_id: u64,
335433
pub loan_id: u64,
336434
pub borrower: AccountId,
435+
pub schedule_type: Schedule,
337436
pub principal_due: u128,
338437
pub interest_due: u128,
339438
pub installment_amount: u128,
@@ -424,6 +523,8 @@ mod propchain_lending {
424523
Cancelled,
425524
}
426525

526+
pub type TokenId = u64;
527+
427528
/// A borrower's public loan request listed on the marketplace (#304).
428529
#[derive(
429530
Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout,
@@ -438,6 +539,8 @@ mod propchain_lending {
438539
pub max_rate_bps: u32,
439540
pub term_months: u32,
440541
pub collateral_kind: CollateralKind,
542+
/// Multi-token collateral basket: (token_id, amount) pairs (#827).
543+
pub collateral_basket: Vec<(TokenId, u128)>,
441544
pub status: ListingStatus,
442545
pub created_at: u64,
443546
/// ID of the accepted offer, if any.
@@ -1168,6 +1271,70 @@ mod propchain_lending {
11681271
Ok(())
11691272
}
11701273

1274+
/// Create a payment schedule for a loan with a specified schedule type (#829).
1275+
#[ink(message)]
1276+
pub fn create_payment_schedule(
1277+
&mut self,
1278+
loan_id: u64,
1279+
schedule_type: Schedule,
1280+
interval_blocks: u64,
1281+
) -> Result<u64, LendingError> {
1282+
let caller = self.env().caller();
1283+
let loan = self
1284+
.loan_applications
1285+
.get(loan_id)
1286+
.ok_or(LendingError::LoanNotFound)?;
1287+
1288+
if caller != self.admin && caller != loan.applicant {
1289+
return Err(LendingError::Unauthorized);
1290+
}
1291+
if interval_blocks == 0 {
1292+
return Err(LendingError::InvalidParameters);
1293+
}
1294+
1295+
// Calculate installment amount based on schedule type
1296+
let installment = schedule_type.installment(
1297+
loan.requested_amount,
1298+
loan.interest_rate_bps,
1299+
loan.term_months,
1300+
interval_blocks,
1301+
);
1302+
1303+
let total_blocks = loan.term_months as u64 * 432_000;
1304+
let total_installments = (total_blocks / interval_blocks) as u32;
1305+
let now = self.env().block_number() as u64;
1306+
1307+
self.schedule_count += 1;
1308+
let schedule = PaymentSchedule {
1309+
schedule_id: self.schedule_count,
1310+
loan_id,
1311+
borrower: loan.applicant,
1312+
schedule_type,
1313+
principal_due: loan.requested_amount,
1314+
interest_due: 0,
1315+
installment_amount: installment,
1316+
total_installments: total_installments.max(1),
1317+
installments_paid: 0,
1318+
first_due_block: now.saturating_add(interval_blocks),
1319+
interval_blocks,
1320+
next_due_block: now.saturating_add(interval_blocks),
1321+
total_paid: 0,
1322+
status: PaymentScheduleStatus::Active,
1323+
};
1324+
self.payment_schedules
1325+
.insert(self.schedule_count, &schedule);
1326+
self.loan_payment_schedule
1327+
.insert(loan_id, &self.schedule_count);
1328+
Ok(self.schedule_count)
1329+
}
1330+
1331+
/// Get the payment schedule for a loan.
1332+
#[ink(message)]
1333+
pub fn get_payment_schedule_by_loan(&self, loan_id: u64) -> Option<PaymentSchedule> {
1334+
let schedule_id = self.loan_payment_schedule.get(loan_id)?;
1335+
self.payment_schedules.get(schedule_id)
1336+
}
1337+
11711338
#[ink(message)]
11721339
pub fn propose_loan_restructuring(
11731340
&mut self,
@@ -1561,8 +1728,9 @@ mod propchain_lending {
15611728

15621729
/// Create a new loan listing on the marketplace (#304).
15631730
///
1564-
/// Any borrower can list their loan request. Lenders can then submit
1565-
/// competing offers via `submit_loan_offer`.
1731+
/// Any borrower can list their loan request with an optional multi-token
1732+
/// collateral basket (#827). Lenders can then submit competing offers
1733+
/// via `submit_loan_offer`.
15661734
#[ink(message)]
15671735
pub fn create_loan_listing(
15681736
&mut self,
@@ -1571,6 +1739,8 @@ mod propchain_lending {
15711739
max_rate_bps: u32,
15721740
term_months: u32,
15731741
collateral_kind: CollateralKind,
1742+
// Multi-token collateral basket: (token_id, amount) pairs (#827).
1743+
collateral_basket: Vec<(TokenId, u128)>,
15741744
) -> Result<u64, LendingError> {
15751745
if requested_amount == 0 || max_rate_bps == 0 || term_months == 0 {
15761746
return Err(LendingError::InvalidParameters);
@@ -1587,6 +1757,7 @@ mod propchain_lending {
15871757
max_rate_bps,
15881758
term_months,
15891759
collateral_kind,
1760+
collateral_basket,
15901761
status: ListingStatus::Open,
15911762
created_at: self.env().block_number() as u64,
15921763
accepted_offer_id: None,

0 commit comments

Comments
 (0)