From f0c2e9ed6a82d67c5615479b162ac73c8319b51c Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Thu, 27 Aug 2026 14:29:01 +0000 Subject: [PATCH] feat(stablecoin): add collateralization-check helper closes #174 --- Cargo.lock | 1 + programs/stablecoin/Cargo.toml | 1 + programs/stablecoin/methods/guest/Cargo.lock | 1 + programs/stablecoin/src/checks.rs | 187 +++++++++++++++++++ programs/stablecoin/src/lib.rs | 3 + 5 files changed, 193 insertions(+) create mode 100644 programs/stablecoin/src/checks.rs diff --git a/Cargo.lock b/Cargo.lock index 551a4564..de2db2b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4067,6 +4067,7 @@ dependencies = [ name = "stablecoin_program" version = "0.1.0" dependencies = [ + "alloy-primitives", "clock_core", "lee_core", "stablecoin_core", diff --git a/programs/stablecoin/Cargo.toml b/programs/stablecoin/Cargo.toml index d077ca5f..09e20d23 100644 --- a/programs/stablecoin/Cargo.toml +++ b/programs/stablecoin/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4" } +alloy-primitives = { version = "1", default-features = false } stablecoin_core = { path = "core" } token_core = { path = "../token/core" } twap_oracle_core = { path = "../twap_oracle/core" } diff --git a/programs/stablecoin/methods/guest/Cargo.lock b/programs/stablecoin/methods/guest/Cargo.lock index f2688927..0fff2de8 100644 --- a/programs/stablecoin/methods/guest/Cargo.lock +++ b/programs/stablecoin/methods/guest/Cargo.lock @@ -2718,6 +2718,7 @@ dependencies = [ name = "stablecoin_program" version = "0.1.0" dependencies = [ + "alloy-primitives", "clock_core", "lee_core", "stablecoin_core", diff --git a/programs/stablecoin/src/checks.rs b/programs/stablecoin/src/checks.rs new file mode 100644 index 00000000..e0b4babc --- /dev/null +++ b/programs/stablecoin/src/checks.rs @@ -0,0 +1,187 @@ +//! Shared validation helpers reused across the position-lifecycle instructions. + +use alloy_primitives::U256; +use stablecoin_core::{math::FIXED_POINT_ONE, Position}; + +/// Assert that `position` satisfies the collateralization invariant from spec §6.2: +/// +/// ```text +/// position.collateral_amount * FIXED_POINT_ONE^2 +/// >= nominal_debt * current_redemption_price * minimum_collateralization_ratio +/// ``` +/// +/// where `nominal_debt = position.normalized_debt_amount * current_accumulator / +/// FIXED_POINT_ONE`. This is the cross-multiplied form the spec prescribes — it +/// divides once instead of twice, so no intermediate rounding creeps into the +/// comparison. +/// +/// Computed in `U256` to avoid intermediate overflow. The caller is responsible for +/// projecting `current_accumulator` and `current_redemption_price` forward to the +/// current timestamp (spec §5.3) before calling; this helper only compares. +/// +/// **A zero-debt position always passes**, regardless of collateral — there is +/// nothing to collateralize. +/// +/// # Panics +/// +/// - `"Position is undercollateralized"` when `lhs >= rhs` does not hold. +/// - When an intermediate product exceeds `U256`. +pub fn assert_position_is_collateralized( + position: &Position, + current_accumulator: u128, + current_redemption_price: u128, + minimum_collateralization_ratio: u128, +) { + if position.normalized_debt_amount == 0 { + return; + } + + let multiply = |a: U256, b: U256| { + a.checked_mul(b) + .expect("collateralization check: intermediate product overflows U256") + }; + + let one = U256::from(FIXED_POINT_ONE); + + let nominal_debt = multiply( + U256::from(position.normalized_debt_amount), + U256::from(current_accumulator), + ) + .checked_div(one) + .expect("collateralization check: FIXED_POINT_ONE is non-zero"); + + let collateral_value = multiply(multiply(U256::from(position.collateral_amount), one), one); + let required_collateral_value = multiply( + multiply(nominal_debt, U256::from(current_redemption_price)), + U256::from(minimum_collateralization_ratio), + ); + + assert!( + collateral_value >= required_collateral_value, + "Position is undercollateralized" + ); +} + +#[cfg(test)] +#[allow( + clippy::arithmetic_side_effects, + clippy::panic, + reason = "tests build fixture ratios from constants and deliberately panic via #[should_panic]" +)] +mod tests { + use lee_core::account::AccountId; + + use super::*; + + fn position_with(collateral_amount: u128, normalized_debt_amount: u128) -> Position { + Position { + owner_account_id: AccountId::new([1u8; 32]), + position_nonce: 0, + vault_account_id: AccountId::new([2u8; 32]), + collateral_amount, + normalized_debt_amount, + opened_at: 0, + } + } + + #[test] + fn zero_debt_passes_even_with_zero_collateral() { + assert_position_is_collateralized( + &position_with(0, 0), + FIXED_POINT_ONE, + FIXED_POINT_ONE, + FIXED_POINT_ONE * 3 / 2, + ); + } + + #[test] + fn zero_debt_passes_with_collateral() { + assert_position_is_collateralized( + &position_with(1_000_000, 0), + FIXED_POINT_ONE, + FIXED_POINT_ONE, + FIXED_POINT_ONE * 3 / 2, + ); + } + + #[test] + fn comfortable_surplus_passes() { + // 1 unit of debt at a 1.0 redemption price needs 1.5 collateral at a 1.5x + // ratio; 10 is far above that. + assert_position_is_collateralized( + &position_with(10, 1), + FIXED_POINT_ONE, + FIXED_POINT_ONE, + FIXED_POINT_ONE * 3 / 2, + ); + } + + #[test] + fn exact_boundary_passes() { + // accumulator, redemption price, and ratio all 1.0, so the requirement is + // exactly `collateral >= normalized_debt`. + assert_position_is_collateralized( + &position_with(100, 100), + FIXED_POINT_ONE, + FIXED_POINT_ONE, + FIXED_POINT_ONE, + ); + } + + #[test] + #[should_panic(expected = "Position is undercollateralized")] + fn one_unit_below_the_boundary_fails() { + assert_position_is_collateralized( + &position_with(99, 100), + FIXED_POINT_ONE, + FIXED_POINT_ONE, + FIXED_POINT_ONE, + ); + } + + #[test] + fn exactly_one_and_a_half_times_collateral_passes() { + // nominal debt 100 at a 0.5 redemption price is worth 50 in collateral + // units; 1.5x of that is 75. + assert_position_is_collateralized( + &position_with(75, 100), + FIXED_POINT_ONE, + FIXED_POINT_ONE / 2, + FIXED_POINT_ONE * 3 / 2, + ); + } + + #[test] + #[should_panic(expected = "Position is undercollateralized")] + fn one_unit_below_one_and_a_half_times_collateral_fails() { + assert_position_is_collateralized( + &position_with(74, 100), + FIXED_POINT_ONE, + FIXED_POINT_ONE / 2, + FIXED_POINT_ONE * 3 / 2, + ); + } + + #[test] + #[should_panic(expected = "Position is undercollateralized")] + fn accumulator_growth_turns_a_passing_position_into_a_failing_one() { + // 80 collateral against normalized debt 100 at a 0.5 redemption price and a + // 1.5x ratio: needs 75 while the accumulator is 1.0, but 90 once the + // accumulator reaches 1.2. + let position = position_with(80, 100); + + assert_position_is_collateralized( + &position, + FIXED_POINT_ONE, + FIXED_POINT_ONE / 2, + FIXED_POINT_ONE * 3 / 2, + ); + + assert_position_is_collateralized( + &position, + FIXED_POINT_ONE * 12 / 10, + FIXED_POINT_ONE / 2, + FIXED_POINT_ONE * 3 / 2, + ); + } +} diff --git a/programs/stablecoin/src/lib.rs b/programs/stablecoin/src/lib.rs index 58e4c723..c2474370 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -5,6 +5,9 @@ pub use stablecoin_core as core; /// Permissionless poke: advance the global stability-fee accumulator. pub mod accrue_stability_fee; +/// Shared validation helpers reused across the position-lifecycle instructions. +pub mod checks; + /// Bootstrap the protocol: create the global PDAs and the stablecoin definition. pub mod initialize_program;