Skip to content

feat(Contract): Cross-Chain Asset Data Structures and Enums #732#767

Merged
ONEONUORA merged 6 commits into
Fracverse:masterfrom
martinvibes:risk_engine
Jun 18, 2026
Merged

feat(Contract): Cross-Chain Asset Data Structures and Enums #732#767
ONEONUORA merged 6 commits into
Fracverse:masterfrom
martinvibes:risk_engine

Conversation

@martinvibes

@martinvibes martinvibes commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

fix(Contract): account for token decimals in collateral valuation (#732)

Summary

The risk engine's collateral valuation performed monetary arithmetic with raw
Decimal operators that ignored each token's native on-chain precision, could
overflow for high-value positions, and persisted the health factor at a
different precision than it used for the risk comparison. This PR replaces that
logic with a set of small, pure, fully unit-tested helpers that normalize token
amounts to their real precision, value both sides of a position safely, and make
a single consistent risk decision.

No schema changes, no API changes, no behavioral change for correctly-formed
data — this is a correctness and robustness fix for the edges.

Background

RiskEngine::check_all_loans periodically recomputes the health factor of every
active borrowing position and flags positions at risk of liquidation. For each
loan it:

  1. Loads the outstanding debt and collateral amounts from loan_lifecycle.
  2. Fetches fresh USD prices for the borrow and collateral assets.
  3. Computes collateral_value and debt_value in USD.
  4. Derives health_factor = collateral_value / debt_value.
  5. Compares the health factor against a per-asset liquidation threshold and
    persists is_risky + health_factor back onto the plan.

Token amounts are stored as NUMERIC(30, 8) and the health_factor column is
DECIMAL(10, 4).

Problem

The original valuation used raw Decimal * and /:

let collat_value = loan.collateral_amount.unwrap_or(Decimal::ZERO) * collat_price;
let debt_value = loan.total_debt * borrow_price;

if debt_value > Decimal::ZERO {
    let health_factor = collat_value / debt_value;
    // ...inline per-asset threshold match, then compare at full precision...
}

This produced three concrete defects:

1. Token decimals were ignored

Amounts are stored at a flat scale of 8, but each token has its own native
on-chain precision:

Token Native decimals
USDC / USDT 6
XLM 7
BTC / WBTC 8
ETH / WETH 18

Any digits a stored amount carries beyond a token's real precision are dust that
should never have influenced a valuation. For example, a USDC balance with 8
stored decimals carries two decimals of dust beyond USDC's true 6-decimal
precision, which leaks directly into the USD value and, in turn, the health
factor and the liquidation decision.

2. Arithmetic could overflow

rust_decimal carries a 28–29 significant-digit budget. Raw multiplication grows
scale (8 + 8 = 16), so a sufficiently large position (large amount × large price)
can exhaust that budget and silently round, or panic, rather than failing
gracefully. A panic inside the risk-engine loop takes down the whole periodic
scan.

3. Health factor was compared and stored at different precisions

The health factor was compared against the liquidation threshold at full
precision, but persisted to a DECIMAL(10, 4) column. At the threshold boundary
the in-memory risk flag and the value written to the database could disagree,
producing a position that reads as "not risky" in the DB while having been
flagged (or vice versa).

Solution

The valuation and the risk decision are extracted into pure, DB-free helpers,
each independently unit-tested. All arithmetic now goes through the existing
SafeMath module already used elsewhere in the codebase.

Helper Responsibility
token_decimals(asset) Native precision per asset, case-insensitive; unknown assets fall back to the NUMERIC(_, 8) storage scale.
normalize_amount(amount, asset) Rounds a stored amount to its token's native precision, dropping dust.
value_in_usd(amount, price, asset) Normalizes the amount, multiplies by price via SafeMath::mul, and returns the result at a canonical USD valuation scale.
compute_health_factor(collat, debt) SafeMath::div (errors on zero debt), rounded to the DECIMAL(10, 4) storage scale used for both the comparison and persistence.
liquidation_threshold_for_asset(asset, fallback) Per-asset liquidation threshold (previously an inline match), with engine-wide fallback for unknown assets.
assess_position(...) -> Option<PositionAssessment> The full end-to-end risk decision: values both sides, derives the health factor, and flags risk using a strict < comparison.

The consolidated decision: assess_position

struct PositionAssessment {
    collateral_value: Decimal,
    debt_value: Decimal,
    health_factor: Decimal,
    liquidation_threshold: Decimal,
    is_risky: bool,
}

assess_position returns:

  • Ok(None) when there is no positive debt to assess (nothing to flag),
  • Ok(Some(assessment)) with the full breakdown otherwise,
  • Err(..) when any valuation overflows or debt-side arithmetic is invalid.

check_all_loans now delegates to it:

let assessment = match assess_position(
    collat_amount, &collat_asset, collat_price,
    loan.total_debt, &loan.borrow_asset, borrow_price,
    self.liquidation_threshold,
    should_skip_risk_check,
) {
    Ok(Some(a)) => a,
    Ok(None) => continue,                 // no outstanding debt
    Err(e) => {                           // skip, do not panic
        warn!("Risk Engine: skipping plan {} — position assessment failed: {}", loan.plan_id, e);
        continue;
    }
};

Design decisions

  • Strict < comparison. A position sitting exactly at its liquidation
    threshold is not flagged — only positions below it are. This is encoded
    and locked in by a dedicated boundary test.
  • Round before both comparison and storage. The health factor is rounded to
    the storage scale once, then used for both the risk decision and the DB write,
    so the two can never diverge.
  • Skip, don't panic. Any arithmetic failure logs a warning and skips the
    single offending plan; the periodic scan continues for everyone else.
  • Reuse SafeMath. No new arithmetic primitives — the fix builds on the
    overflow-checked helpers already used across the codebase.
  • Unknown assets stay safe. Unknown tokens fall back to the storage scale
    for decimals and the engine-wide threshold for liquidation, so the change is
    conservative for assets not in the table.

Behavior changes

  • Valuations now drop sub-precision dust for known tokens (more accurate health
    factors).
  • High-value positions that previously risked overflow now resolve cleanly or
    skip with a warning instead of panicking.
  • The stored health_factor and the is_risky flag are always consistent at
    the threshold boundary.
  • Positions with zero outstanding debt are skipped explicitly (unchanged
    outcome, clearer control flow).

Testing

20 unit tests, all exercising real behavior (no DB, no mocks), written test-first:

  • Token decimals: known assets, case-insensitivity, unknown-asset fallback
  • Normalization: dust dropped below native precision, high-precision tokens preserved
  • Valuation: canonical-scale output, high-value overflow safety
  • Health factor: rounding to storage scale, repeating-decimal boundary consistency, zero-debt error
  • assess_position: healthy vs. undercollateralized, exact-threshold boundary, risk override, zero-debt → None, per-asset threshold selection, decimals normalization, overflow → Err

Verification

cargo test --lib risk_engine     # 20 passed
cargo test --lib                 # 289 passed, 0 failed
cargo fmt --check                # clean
cargo clippy --lib               # clean for risk_engine.rs

Files changed

  • backend/src/risk_engine.rs

Backwards compatibility

  • No database migrations.
  • No public API or endpoint changes.
  • No changes to the risk-engine scheduling or notification behavior.
  • Fully additive at the module level; existing callers of check_all_loans are
    unaffected.

Checklist

  • Token-decimal normalization implemented for collateral and debt valuation
  • Overflow-safe arithmetic via SafeMath
  • Consistent health-factor precision for comparison and storage
  • Unit tests added for every new function, including edge cases
  • cargo fmt / cargo clippy clean
  • Full backend lib test suite green

martinvibes and others added 4 commits June 18, 2026 06:50
…cverse#632)

The risk engine valued collateral and debt with raw Decimal `*` and `/`,
which (1) ignored each token's native on-chain precision, letting sub-unit
dust retained by the flat NUMERIC(_,8) storage scale distort valuations;
(2) could overflow rust_decimal's 28-significant-digit budget for
high-value positions; and (3) compared the health factor at full precision
while persisting it as DECIMAL(10,4), so the risk flag and the stored value
could disagree at the liquidation-threshold boundary.

Introduce pure, unit-tested helpers:
- token_decimals(): native precision per asset (USDC 6, XLM 7, BTC 8, ETH 18)
- normalize_amount(): round amounts to a token's native precision
- value_in_usd(): normalize then price via SafeMath at a canonical USD scale
- compute_health_factor(): SafeMath division rounded to the storage scale

check_all_loans() now uses these and skips a plan (with a warning) instead
of risking a panic when valuation arithmetic fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ONEONUORA

Copy link
Copy Markdown
Contributor

@martinvibes Pls kindly implement your issue description

martinvibes added 2 commits June 18, 2026 11:59

@ONEONUORA ONEONUORA left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Great job @martinvibes

@ONEONUORA ONEONUORA merged commit 04b0778 into Fracverse:master Jun 18, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Issue #1 - Contract: Cross-Chain Asset Data Structures and Enums

2 participants