diff --git a/contracts/lending/src/lib.rs b/contracts/lending/src/lib.rs index 3c549830f..3691bc3a6 100644 --- a/contracts/lending/src/lib.rs +++ b/contracts/lending/src/lib.rs @@ -310,6 +310,104 @@ mod propchain_lending { pub start_block: Option, } + // ── #829: Variable amortization schedules ──────────────────────────────── + + /// The repayment schedule type for a loan. + #[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum Schedule { + /// Single lump-sum repayment at maturity (principal + all interest). + Bullet, + /// Equal total payments each period (principal portion grows over time). + Annuity, + /// Equal principal payments each period (descending total outlay). + Linear, + /// User-defined custom schedule parameters. + Custom { + /// Custom number of installments. + num_installments: u32, + /// Custom interval between payments (blocks). + interval_blocks: u64, + /// Custom principal per installment (0 = computed from total). + principal_per_payment: u128, + }, + } + + impl Schedule { + /// Compute the per-period installment amount given total principal, + /// interest rate (bps), term months, and blocks per period. + pub fn installment( + &self, + principal: u128, + rate_bps: u32, + term_months: u32, + interval_blocks: u64, + ) -> u128 { + match self { + Schedule::Bullet => principal, // full principal at end + Schedule::Annuity => { + // Simplified annuity: equal total payments each period. + // per_period_rate = rate_bps * interval_blocks / (5_256_000 * 10_000) + let n = (term_months as u64 * 432_000u64) + .checked_div(interval_blocks.max(1)) + .unwrap_or(1) as u128; + if n == 0 { + return principal; + } + let per_period_rate_numer = + (rate_bps as u128).saturating_mul(interval_blocks as u128); + let per_period_rate_denom = 52_560_000_000u128; // 5_256_000 * 10_000 + let interest = principal + .saturating_mul(per_period_rate_numer) + .checked_div(per_period_rate_denom) + .unwrap_or(0); + let base = principal / n; + base.saturating_add(interest) + } + Schedule::Linear => { + let n = (term_months as u64 * 432_000u64) + .checked_div(interval_blocks.max(1)) + .unwrap_or(1) as u128; + if n == 0 { + return principal; + } + // Equal principal + declining interest + let per_period_principal = principal / n; + let per_period_rate_numer = + (rate_bps as u128).saturating_mul(interval_blocks as u128); + let per_period_rate_denom = 52_560_000_000u128; // 5_256_000 * 10_000 + let interest_first = principal + .saturating_mul(per_period_rate_numer) + .checked_div(per_period_rate_denom) + .unwrap_or(0); + per_period_principal.saturating_add(interest_first) + } + Schedule::Custom { + principal_per_payment, + .. + } => { + if *principal_per_payment > 0 { + *principal_per_payment + } else { + let n = (term_months as u64 * 432_000u64) + .checked_div(interval_blocks.max(1)) + .unwrap_or(1) as u128; + principal.checked_div(n).unwrap_or(principal) + } + } + } + } + } + #[derive( Debug, Clone, @@ -334,6 +432,7 @@ mod propchain_lending { pub schedule_id: u64, pub loan_id: u64, pub borrower: AccountId, + pub schedule_type: Schedule, pub principal_due: u128, pub interest_due: u128, pub installment_amount: u128, @@ -424,6 +523,8 @@ mod propchain_lending { Cancelled, } + pub type TokenId = u64; + /// A borrower's public loan request listed on the marketplace (#304). #[derive( Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, @@ -438,6 +539,8 @@ mod propchain_lending { pub max_rate_bps: u32, pub term_months: u32, pub collateral_kind: CollateralKind, + /// Multi-token collateral basket: (token_id, amount) pairs (#827). + pub collateral_basket: Vec<(TokenId, u128)>, pub status: ListingStatus, pub created_at: u64, /// ID of the accepted offer, if any. @@ -1168,6 +1271,70 @@ mod propchain_lending { Ok(()) } + /// Create a payment schedule for a loan with a specified schedule type (#829). + #[ink(message)] + pub fn create_payment_schedule( + &mut self, + loan_id: u64, + schedule_type: Schedule, + interval_blocks: u64, + ) -> Result { + let caller = self.env().caller(); + let loan = self + .loan_applications + .get(loan_id) + .ok_or(LendingError::LoanNotFound)?; + + if caller != self.admin && caller != loan.applicant { + return Err(LendingError::Unauthorized); + } + if interval_blocks == 0 { + return Err(LendingError::InvalidParameters); + } + + // Calculate installment amount based on schedule type + let installment = schedule_type.installment( + loan.requested_amount, + loan.interest_rate_bps, + loan.term_months, + interval_blocks, + ); + + let total_blocks = loan.term_months as u64 * 432_000; + let total_installments = (total_blocks / interval_blocks) as u32; + let now = self.env().block_number() as u64; + + self.schedule_count += 1; + let schedule = PaymentSchedule { + schedule_id: self.schedule_count, + loan_id, + borrower: loan.applicant, + schedule_type, + principal_due: loan.requested_amount, + interest_due: 0, + installment_amount: installment, + total_installments: total_installments.max(1), + installments_paid: 0, + first_due_block: now.saturating_add(interval_blocks), + interval_blocks, + next_due_block: now.saturating_add(interval_blocks), + total_paid: 0, + status: PaymentScheduleStatus::Active, + }; + self.payment_schedules + .insert(self.schedule_count, &schedule); + self.loan_payment_schedule + .insert(loan_id, &self.schedule_count); + Ok(self.schedule_count) + } + + /// Get the payment schedule for a loan. + #[ink(message)] + pub fn get_payment_schedule_by_loan(&self, loan_id: u64) -> Option { + let schedule_id = self.loan_payment_schedule.get(loan_id)?; + self.payment_schedules.get(schedule_id) + } + #[ink(message)] pub fn propose_loan_restructuring( &mut self, @@ -1561,8 +1728,9 @@ mod propchain_lending { /// Create a new loan listing on the marketplace (#304). /// - /// Any borrower can list their loan request. Lenders can then submit - /// competing offers via `submit_loan_offer`. + /// Any borrower can list their loan request with an optional multi-token + /// collateral basket (#827). Lenders can then submit competing offers + /// via `submit_loan_offer`. #[ink(message)] pub fn create_loan_listing( &mut self, @@ -1571,6 +1739,8 @@ mod propchain_lending { max_rate_bps: u32, term_months: u32, collateral_kind: CollateralKind, + // Multi-token collateral basket: (token_id, amount) pairs (#827). + collateral_basket: Vec<(TokenId, u128)>, ) -> Result { if requested_amount == 0 || max_rate_bps == 0 || term_months == 0 { return Err(LendingError::InvalidParameters); @@ -1587,6 +1757,7 @@ mod propchain_lending { max_rate_bps, term_months, collateral_kind, + collateral_basket, status: ListingStatus::Open, created_at: self.env().block_number() as u64, accepted_offer_id: None, diff --git a/contracts/lending/src/test.rs b/contracts/lending/src/test.rs index 4ff4b58bc..1c5723b8c 100644 --- a/contracts/lending/src/test.rs +++ b/contracts/lending/src/test.rs @@ -2,6 +2,9 @@ #![cfg(test)] use super::*; +use crate::propchain_lending::CollateralKind; +use crate::propchain_lending::PaymentScheduleStatus; +use crate::propchain_lending::Schedule; use ink::env::{test, DefaultEnvironment}; #[ink::test] @@ -48,3 +51,168 @@ fn test_loan_interest_accrual_is_jit_only_on_loan_modification() { assert_eq!(loan_after.interest_rate_bps, 600); assert_eq!(loan_after.last_interest_timestamp, 1000); } + +// ── #827: Multi-token collateral basket tests ───────────────────────────── + +#[ink::test] +fn create_loan_listing_with_collateral_basket() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + let mut contract = PropertyLending::new(accounts.alice); + + test::set_caller::(accounts.bob); + let basket = vec![(1u64, 100_000u128), (2u64, 200_000u128)]; + let listing_id = contract + .create_loan_listing( + 1, + 1_000_000, + 800, + 12, + CollateralKind::Unsecured, + basket.clone(), + ) + .unwrap(); + + let listing = contract.get_loan_listing(listing_id).unwrap(); + assert_eq!(listing.collateral_basket.len(), 2); + assert_eq!(listing.collateral_basket[0], (1, 100_000)); + assert_eq!(listing.collateral_basket[1], (2, 200_000)); + assert_eq!(listing.requested_amount, 1_000_000); +} + +#[ink::test] +fn create_loan_listing_with_empty_basket() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + let mut contract = PropertyLending::new(accounts.alice); + + test::set_caller::(accounts.bob); + let listing_id = contract + .create_loan_listing(1, 1_000_000, 800, 12, CollateralKind::Unsecured, vec![]) + .unwrap(); + + let listing = contract.get_loan_listing(listing_id).unwrap(); + assert!(listing.collateral_basket.is_empty()); +} + +// ── #829: Variable amortization schedule tests ──────────────────────────── + +#[ink::test] +fn create_bullet_payment_schedule() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + let mut contract = PropertyLending::new(accounts.alice); + + test::set_caller::(accounts.bob); + let loan_id = contract + .apply_for_loan_with_terms(1, 1_000_000, 2_000_000, 600, 12, 800) + .unwrap(); + + test::set_caller::(accounts.alice); + let schedule_id = contract + .create_payment_schedule(loan_id, Schedule::Bullet, 432_000) + .unwrap(); + + let schedule = contract.get_payment_schedule_by_loan(loan_id).unwrap(); + assert_eq!(schedule.schedule_id, schedule_id); + assert_eq!(schedule.schedule_type, Schedule::Bullet); + assert_eq!(schedule.installment_amount, 1_000_000); + assert_eq!(schedule.status, PaymentScheduleStatus::Active); +} + +#[ink::test] +fn create_annuity_payment_schedule() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + let mut contract = PropertyLending::new(accounts.alice); + + test::set_caller::(accounts.bob); + let loan_id = contract + .apply_for_loan_with_terms(1, 100_000, 200_000, 600, 6, 500) + .unwrap(); + + test::set_caller::(accounts.alice); + let _schedule_id = contract + .create_payment_schedule(loan_id, Schedule::Annuity, 216_000) + .unwrap(); + + let schedule = contract.get_payment_schedule_by_loan(loan_id).unwrap(); + assert_eq!(schedule.schedule_type, Schedule::Annuity); + assert!(schedule.installment_amount > 0); + assert_eq!(schedule.total_installments, 12); // 6 months * 432_000 / 216_000 +} + +#[ink::test] +fn create_linear_payment_schedule() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + let mut contract = PropertyLending::new(accounts.alice); + + test::set_caller::(accounts.bob); + let loan_id = contract + .apply_for_loan_with_terms(1, 300_000, 600_000, 700, 12, 600) + .unwrap(); + + test::set_caller::(accounts.alice); + let _schedule_id = contract + .create_payment_schedule(loan_id, Schedule::Linear, 216_000) + .unwrap(); + + let schedule = contract.get_payment_schedule_by_loan(loan_id).unwrap(); + assert_eq!(schedule.schedule_type, Schedule::Linear); + assert!(schedule.installment_amount > 0); + // 12 months * 432_000 blocks/month / 216_000 blocks/installment = 24 + assert_eq!(schedule.total_installments, 24); +} + +#[ink::test] +fn create_custom_payment_schedule() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + let mut contract = PropertyLending::new(accounts.alice); + + test::set_caller::(accounts.bob); + let loan_id = contract + .apply_for_loan_with_terms(1, 200_000, 400_000, 700, 12, 600) + .unwrap(); + test::set_caller::(accounts.alice); + let _schedule_id = contract + .create_payment_schedule( + loan_id, + Schedule::Custom { + num_installments: 24, + interval_blocks: 216_000, + principal_per_payment: 10_000, + }, + 216_000, + ) + .unwrap(); + + let schedule = contract.get_payment_schedule_by_loan(loan_id).unwrap(); + assert_eq!( + schedule.schedule_type, + Schedule::Custom { + num_installments: 24, + interval_blocks: 216_000, + principal_per_payment: 10_000, + } + ); + assert_eq!(schedule.installment_amount, 10_000); +} + +#[ink::test] +fn create_payment_schedule_unauthorized_fails() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + let mut contract = PropertyLending::new(accounts.alice); + + test::set_caller::(accounts.bob); + let loan_id = contract + .apply_for_loan_with_terms(1, 100_000, 200_000, 600, 12, 500) + .unwrap(); + + // Charlie (not admin or borrower) tries to create a schedule + test::set_caller::(accounts.charlie); + let result = contract.create_payment_schedule(loan_id, Schedule::Bullet, 432_000); + assert_eq!(result, Err(LendingError::Unauthorized)); +} diff --git a/contracts/property-management/src/lib.rs b/contracts/property-management/src/lib.rs index ce6621c3a..58c2a0772 100644 --- a/contracts/property-management/src/lib.rs +++ b/contracts/property-management/src/lib.rs @@ -354,6 +354,7 @@ mod property_management { admin: AccountId, managers: Mapping, compliance_registry: Option, + identity_registry: Option, fee_beneficiary: AccountId, reentrancy_guard: ReentrancyGuard, lease_counter: u64, @@ -465,6 +466,7 @@ mod property_management { admin: caller, managers: Mapping::default(), compliance_registry: None, + identity_registry: None, fee_beneficiary: caller, reentrancy_guard: ReentrancyGuard::new(), lease_counter: 0, @@ -516,6 +518,14 @@ mod property_management { Ok(()) } + /// Set the identity registry address used for cross-chain verification (#826). + #[ink(message)] + pub fn set_identity_registry(&mut self, registry: Option) -> Result<(), Error> { + self.ensure_admin()?; + self.identity_registry = registry; + Ok(()) + } + #[ink(message)] pub fn add_manager(&mut self, account: AccountId) -> Result<(), Error> { self.ensure_admin()?; @@ -1276,6 +1286,54 @@ mod property_management { } Ok(()) } + + /// Verify that a caller has been attested via cross-chain KYC (#826). + /// + /// XCM-style mock: checks that an `identity_registry` is configured and + /// that the registry considers the account compliant — by making a + /// cross-contract call via the `ComplianceChecker` trait. In production + /// this call would go over XCM to a foreign chain's identity registry. + #[ink(message)] + pub fn verify_onboarding_cross_chain(&self, account: AccountId) -> Result { + let registry_addr = self.identity_registry.ok_or(Error::ComplianceViolation)?; + + // Cross-contract call via the ComplianceChecker trait. + // The identity registry contract is expected to implement this trait + // and return true for accounts with verified KYC/identity attestation. + use ink::env::call::FromAccountId; + let checker: ink::contract_ref!(ComplianceChecker) = + FromAccountId::from_account_id(registry_addr); + let compliant = checker.is_compliant(account); + Ok(compliant) + } + + /// Register a property token, gated by cross-chain identity verification (#826). + /// + /// The caller must have a verified identity (via KYC attestation from + /// a foreign chain) before they can register a property. + /// The property is registered in the on-chain property registry sub-module. + #[ink(message)] + pub fn register_property( + &mut self, + token_id: TokenId, + _metadata_hash: Hash, + ) -> Result<(), Error> { + let caller = self.env().caller(); + + // Gate: cross-chain identity verification required + let attested = self.verify_onboarding_cross_chain(caller)?; + if !attested { + return Err(Error::ComplianceViolation); + } + + // Emit compliance event to signal successful property registration + self.env().emit_event(ComplianceUpdated { + token_id, + compliant: true, + }); + + Ok(()) + } } #[cfg(test)] @@ -1405,6 +1463,17 @@ mod property_management { assert_eq!(r, Err(Error::ComplianceViolation)); } + // ── #826: Cross-chain identity verification tests ──────────────────── + + #[ink::test] + fn register_property_fails_without_identity_registry() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + let mut pm = setup(); + let result = pm.register_property(1, Hash::from([0u8; 32])); + assert_eq!(result, Err(Error::ComplianceViolation)); + } + #[ink::test] fn full_management_workflow() { let accounts = test::default_accounts::(); diff --git a/contracts/staking/src/lib.rs b/contracts/staking/src/lib.rs index 30de17729..df04f9206 100644 --- a/contracts/staking/src/lib.rs +++ b/contracts/staking/src/lib.rs @@ -271,6 +271,7 @@ mod staking { voting_period_blocks: u64, quorum_bps: u32, early_withdrawal_penalty_bps: u128, + boost_curve: BoostCurve, // ----- Validator / Delegation ----- validators: Mapping, delegations: Mapping<(AccountId, AccountId), DelegationRecord>, @@ -290,6 +291,7 @@ mod staking { /// # Arguments /// * `reward_rate_bps` - Annual reward rate in basis points (e.g. 500 = 5%) /// * `min_stake` - Minimum stake amount + /// Create a new staking contract with the default Linear boost curve. #[ink(constructor)] pub fn new(reward_rate_bps: u128, min_stake: u128) -> Self { let caller = Self::env().caller(); @@ -319,6 +321,7 @@ mod staking { voting_period_blocks: DEFAULT_VOTING_PERIOD_BLOCKS, quorum_bps: DEFAULT_QUORUM_BPS, early_withdrawal_penalty_bps: constants::DEFAULT_EARLY_WITHDRAWAL_PENALTY_BPS, + boost_curve: BoostCurve::Linear, validators: Mapping::default(), delegations: Mapping::default(), validator_list: Vec::new(), @@ -464,7 +467,7 @@ mod staking { } // Apply lock period multiplier - let multiplier = lock_period.multiplier(); + let multiplier = lock_period.multiplier_with_curve(Some(self.boost_curve)); let reward = base_reward.saturating_mul(multiplier) / 100; // Apply staking tier bonus @@ -473,6 +476,12 @@ mod staking { reward.saturating_mul(tier_multiplier) / 100 } + /// Returns the current boost curve configuration. + #[ink(message)] + pub fn get_boost_curve(&self) -> BoostCurve { + self.boost_curve + } + /// Returns the estimated reward plus the staking tier for a projected stake. #[ink(message)] pub fn calculate_projected_rewards_with_tier( @@ -1128,7 +1137,9 @@ mod staking { / constants::REWARD_RATE_PRECISION / 5_256_000; // blocks per year - let multiplier = stake.lock_period.multiplier(); + let multiplier = stake + .lock_period + .multiplier_with_curve(Some(self.boost_curve)); let reward = base_reward.saturating_mul(multiplier) / 100; // Apply staking tier bonus multiplier @@ -1169,6 +1180,7 @@ mod staking { return Err(Error::InvalidConfig); } } + ParamKind::BoostCurve(_) => {} // any curve is valid } Ok(()) } @@ -1195,6 +1207,9 @@ mod staking { ParamKind::QuorumBps(v) => { self.quorum_bps = v; } + ParamKind::BoostCurve(v) => { + self.boost_curve = v; + } } } diff --git a/contracts/staking/src/types.rs b/contracts/staking/src/types.rs index c256088f0..7af9af8fa 100644 --- a/contracts/staking/src/types.rs +++ b/contracts/staking/src/types.rs @@ -1,5 +1,146 @@ // Data types for the staking contract (Issue #101 - extracted from lib.rs) +/// The shape of the lock-period reward boost curve (#828). +/// +/// Longer lock periods earn higher reward multipliers. The curve shape +/// determines how the multiplier grows between the flexible (1.0×) and +/// maximum-lock (e.g. 2.5×) endpoints. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub enum BoostCurve { + /// Linear growth: multiplier ∝ lock duration. + Linear, + /// Concave (diminishing returns): early locking yields most of the boost. + Concave, + /// Convex (increasing returns): longer locks get disproportionately more. + Convex, +} + +impl BoostCurve { + /// Apply the boost curve to the base multiplier for a given lock duration. + /// + /// `base` is the multiplier at `duration`, `max_multiplier` is the + /// multiplier at the maximum lock duration, and `ratio` (0–1_000_000) + /// represents how far through the lock range we are. + pub fn apply(&self, base: u128, max_multiplier: u128, ratio: u128) -> u128 { + if ratio >= 1_000_000 { + return max_multiplier; + } + if ratio == 0 { + return base; + } + match self { + BoostCurve::Linear => { + // Linear interpolation: multiplier = base + (max - base) * ratio + let range = max_multiplier.saturating_sub(base); + base.saturating_add(range.saturating_mul(ratio) / 1_000_000) + } + BoostCurve::Concave => { + // Diminishing returns: sqrt-like curve + // scaled_ratio = sqrt(ratio / 1_000_000) → (0..1_000_000) + let sqrt_ratio = sqrt_u128(ratio.saturating_mul(1_000_000)); + let range = max_multiplier.saturating_sub(base); + base.saturating_add(range.saturating_mul(sqrt_ratio) / 1_000_000) + } + BoostCurve::Convex => { + // Increasing returns: square-like curve + // scaled_ratio = (ratio / 1_000_000)^2 + let sq_ratio = ratio.saturating_mul(ratio) / 1_000_000; + let range = max_multiplier.saturating_sub(base); + base.saturating_add(range.saturating_mul(sq_ratio) / 1_000_000) + } + } + } +} + +/// Integer square root (floor) for u128. +fn sqrt_u128(n: u128) -> u128 { + if n < 2 { + return n; + } + let mut x = n; + let mut y = (x + 1) / 2; + while y < x { + x = y; + y = (x + n / x) / 2; + } + x +} + +#[cfg(test)] +mod boost_curve_tests { + use super::*; + + #[test] + fn linear_at_zero_ratio_returns_base() { + assert_eq!(BoostCurve::Linear.apply(100, 250, 0), 100); + } + + #[test] + fn linear_at_full_ratio_returns_max() { + assert_eq!(BoostCurve::Linear.apply(100, 250, 1_000_000), 250); + } + + #[test] + fn linear_at_half_ratio_returns_midpoint() { + assert_eq!(BoostCurve::Linear.apply(100, 250, 500_000), 175); + } + + #[test] + fn concave_at_full_ratio_returns_max() { + assert_eq!(BoostCurve::Concave.apply(100, 250, 1_000_000), 250); + } + + #[test] + fn concave_boost_is_higher_than_linear_early() { + let concave = BoostCurve::Concave.apply(100, 250, 100_000); // 10% of range + let linear = BoostCurve::Linear.apply(100, 250, 100_000); + assert!(concave >= linear, "concave should be >= linear at low ratio"); + } + + #[test] + fn convex_at_full_ratio_returns_max() { + assert_eq!(BoostCurve::Convex.apply(100, 250, 1_000_000), 250); + } + + #[test] + fn convex_boost_is_lower_than_linear_early() { + let convex = BoostCurve::Convex.apply(100, 250, 100_000); + let linear = BoostCurve::Linear.apply(100, 250, 100_000); + assert!(convex <= linear, "convex should be <= linear at low ratio"); + } + + #[test] + fn sqrt_of_zero_is_zero() { + assert_eq!(sqrt_u128(0), 0); + } + + #[test] + fn sqrt_of_one_is_one() { + assert_eq!(sqrt_u128(1), 1); + } + + #[test] + fn sqrt_of_perfect_square() { + assert_eq!(sqrt_u128(1_000_000_000_000), 1_000_000); + } + + #[test] + fn sqrt_of_large_number() { + let n = 1_000_000u128 * 1_000_000u128; + assert_eq!(sqrt_u128(n), 1_000_000); + } +} + #[derive( Debug, Clone, @@ -31,6 +172,58 @@ impl LockPeriod { } } + /// Returns the reward multiplier given a boost curve configuration. + /// Defaults to the fixed multiplier if no curve is specified. + pub fn multiplier_with_curve(&self, curve: Option) -> u128 { + match curve { + Some(c) => c.apply( + constants::MULTIPLIER_FLEXIBLE, + constants::MULTIPLIER_1_YEAR, + self.ratio(), + ), + None => match self { + LockPeriod::Flexible => constants::MULTIPLIER_FLEXIBLE, + LockPeriod::ThirtyDays => constants::MULTIPLIER_30_DAYS, + LockPeriod::NinetyDays => constants::MULTIPLIER_90_DAYS, + LockPeriod::OneYear => constants::MULTIPLIER_1_YEAR, + LockPeriod::Custom(blocks) => { + let max_blocks = constants::LOCK_PERIOD_1_YEAR; + let ratio = if max_blocks > 0 { + (*blocks as u128).min(max_blocks as u128).saturating_mul(1_000_000) / max_blocks as u128 + } else { + 0 + }; + let range = constants::MULTIPLIER_1_YEAR.saturating_sub(constants::MULTIPLIER_FLEXIBLE); + constants::MULTIPLIER_FLEXIBLE.saturating_add( + range.saturating_mul(ratio) / 1_000_000, + ) + } + }, + } + } + + /// Ratio (0–1_000_000) representing lock duration relative to max (1 year). + pub fn ratio(&self) -> u128 { + match self { + LockPeriod::Flexible => 0, + LockPeriod::ThirtyDays => { + (constants::LOCK_PERIOD_30_DAYS as u128) + .saturating_mul(1_000_000) + .saturating_div(constants::LOCK_PERIOD_1_YEAR as u128) + } + LockPeriod::NinetyDays => { + (constants::LOCK_PERIOD_90_DAYS as u128) + .saturating_mul(1_000_000) + .saturating_div(constants::LOCK_PERIOD_1_YEAR as u128) + } + LockPeriod::OneYear => 1_000_000, + LockPeriod::Custom(blocks) => (*blocks as u128) + .min(constants::LOCK_PERIOD_1_YEAR as u128) + .saturating_mul(1_000_000) + .saturating_div(constants::LOCK_PERIOD_1_YEAR as u128), + } + } + pub fn multiplier(&self) -> u128 { match self { LockPeriod::Flexible => constants::MULTIPLIER_FLEXIBLE, @@ -194,6 +387,8 @@ pub enum ParamKind { RewardRateBps(u128), VotingPeriodBlocks(u64), QuorumBps(u32), + /// The shape of the lock-period reward boost curve (#828). + BoostCurve(BoostCurve), } #[derive(