Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions programs/stablecoin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
1 change: 1 addition & 0 deletions programs/stablecoin/methods/guest/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

187 changes: 187 additions & 0 deletions programs/stablecoin/src/checks.rs
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

U256 is not wide enough for these cross-products over the function's valid u128 input domain. Since FIXED_POINT_ONE^2 == 10^54, the left side already overflows when collateral_amount reaches
115792089237316195423571. With that collateral, debt 1, accumulator and redemption price FIXED_POINT_ONE, and ratio 1.1 * FIXED_POINT_ONE, the position is comfortably collateralized but this multiplication panics before the comparison. Token supply, holding balance, and position collateral have no
smaller bound. Please use a width that holds the complete products (for example U512) or an overflow-free exact comparison, and add a regression at this boundary.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concrete trigger:

position.collateral_amount       = 115792089237316195423571
position.normalized_debt_amount  = 1
current_accumulator              = 1000000000000000000000000000
current_redemption_price         = 1000000000000000000000000000
minimum_collateralization_ratio  = 1100000000000000000000000000

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,
);
}
}
3 changes: 3 additions & 0 deletions programs/stablecoin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading