Skip to content

Integer precision loss in on-chain drift calculation causes phantom rebalances and missed real drift for low-price assets #29

Description

@Uchechukwu-Ekezie

Summary

check_rebalance_needed computes each asset's current portfolio weight using sequential integer division:

// contracts/src/lib.rs
let current_asset_value = (current_balance * price_data.price) / 10i128.pow(14);
let current_percent = (current_asset_value * 100) / total_value;

let drift = (current_percent as i128 - target_percent as i128).abs();
if drift > portfolio.rebalance_threshold as i128 {
    return true;
}

Both intermediate divisions truncate toward zero before accumulating further computation, producing systematic rounding error that scales with the number of assets and their relative magnitudes.

Concrete Example

Portfolio: 3 assets, target 33%/33%/34%, threshold = 2%.

Asset Balance Price (scaled ×10^7) Actual value current_asset_value (after /10^14) current_percent (after ×100/total)
XLM 10_000_000 3_540_000 35.40 0 (lost in division) 0
USDC 10_000_000 10_000_000 100.00 1 100%
BTC 100 1_100_000_000_000 110_000.00 110_000 ~100%

XLM's entire value is zeroed out by integer truncation (10_000_000 × 3_540_000 = 3.54×10^13, divided by 10^14 = 0). The contract then reports XLM drift of 33% — a phantom rebalance trigger every single check, even when the portfolio is perfectly balanced.

Impact

  • Phantom rebalance storms: XLM-heavy portfolios trigger execute_rebalance every cooldown cycle due to computed drift of ~33% when true drift is 0%.
  • Missed real drift: Conversely, assets with large balances but small prices can have their drift absorbed into rounding noise, suppressing legitimate rebalances.
  • Cooldown exploitation: Phantom triggers burn the 3600-second cooldown, preventing real rebalances when the market actually drifts.

Root Cause

The Reflector oracle scales prices by 10^7. Balances stored as raw token amounts (typically 7-decimal Stellar tokens = 10^7 scale). current_balance × price = 10^7 × 10^7 = 10^14, which survives division by 10^14 — but only for assets with balance ≥ 10^7. Sub-unit balances are zeroed.

The 10^14 divisor hardcoding does not account for assets with different decimal precisions.

Suggested Fix

Defer all division to the final percentage comparison and use basis points (10_000) instead of percent (100) to preserve 2 decimal places of drift resolution:

// Compute in basis points (1 bp = 0.01%) to avoid early truncation
let current_asset_value_scaled = current_balance * price_data.price; // keep full precision
let current_bp = (current_asset_value_scaled * 10_000) / (total_value_scaled);
let target_bp = target_percent as i128 * 100; // convert % to bp
let drift_bp = (current_bp - target_bp).abs();
if drift_bp > portfolio.rebalance_threshold as i128 * 100 {
    return true;
}

Also parameterize the price scale per-asset rather than hardcoding 10^14.

References

  • contracts/src/lib.rscheck_rebalance_needed
  • backend/src/services/reflector.tsREFLECTOR_PRICE_SCALE = 1e7

Severity: High — silent precision loss triggers incorrect rebalances on every cycle for common asset combinations

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions