tradi3_ Kurt Landman
Public report archive

Metric T3-004

Fixed-E8 Pyth normalization underprices standard Base MOG/USDC and transfers 5.28% of LP principal

LazerConsumer._normalize() floors signed Pyth Lazer prices with exponents below -8 into integer E8 units before Q64 conversion, underpricing Base MOG/USD (exponent -10) by 5.48% and transferring 5.28% of one-sided LP principal to traders.

Severity
Medium
Outcome
paid
Competition
Metric
Vulnerability class
oracle normalization
Published

Summary

Accepted and paid Medium finding in the Sherlock Metric contest (2026-07-metric @ d2706927622678939ad77d8389159973192ea19c).

LazerConsumer._normalize() converts every signed Pyth Lazer price into an 8-decimal integer (TARGET_DECIMALS = 8) using truncating integer division before the quote reaches AnchoredPriceProvider or MetricOmmPool. When a supported Pyth feed uses an exponent below -8 and a small integer mantissa, flooring to E8 discards significant precision without widening the spread or reverting.

On Base (an explicit deployment chain in scope), Pyth’s official Crypto.MOG/USD feed (priceFeedId = 152) uses exponent = -10. At the captured live Pyth update (price = 1058, $0.0000001058), _normalize() floors 1058 / 100 to 10 E8 units ($0.00000010), underpricing MOG by 5.482% while computing only a 19-bps confidence spread.

Root cause

In smart-contracts-poc/contracts/oracles/utils/LazerConsumer.sol (lines 330-357):

int256 totalExpo = expo + TARGET_DECIMALS;
require(totalExpo >= -57 && totalExpo <= 57);

uint256 rawPrice;
if (totalExpo >= 0) {
    rawPrice = pU * (10 ** totalExpo.toUint256());
} else {
    rawPrice = pU / (10 ** (-totalExpo).toUint256());
}

normPrice = rawPrice.toUint64();

spreadU = Math.ceilDiv(BPS_BASE * uint256(conf), pU);

For Pyth Lazer feed 152 (Crypto.MOG/USD):

signed price       = 1058
signed exponent    = -10
totalExpo          = -10 + 8 = -2
correct USD price  = 1058 * 10^-10 = $0.0000001058 (10.58 E8 units)
stored E8 midpoint = floor(1058 / 100) = 10 ($0.00000010)
midpoint error     = 5.48204159%

Because spreadU is computed against the untruncated pU (ceilDiv(10_000 * 2, 1058) = 19 bps), no spread compensation is added for the 548-bps truncation loss. AnchoredPriceProvider then converts the already-truncated E8 integer 10 into Q64, even though Q64 has ample precision to represent $0.0000001058 directly.

Proof of concept

Using the canonical Base token addresses:

  • Base MOG (token0): 0x2Da56AcB9Ea78330f947bD57C54119Debda7AF71 (18 decimals)
  • Base USDC (token1): 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 (6 decimals)

Because address(MOG) < address(USDC), canonical pool ordering places MOG as token0, where a floored midpoint directly cheapens buying token0 from the pool:

  1. An LP deposits 10,000,000,000 MOG (worth 1,058.00 USDC at the authenticated Pyth price $0.0000001058) into a token0-side bin.
  2. A trader executes a swap through the real PythOracle, AnchoredPriceProvider, and MetricOmmPool.swap().
  3. The trader buys all 10,000,000,000 MOG for 1,002.1002 USDC, capturing 55.8998 USDC (5.28% of the LP’s principal) in a single swap.

Preserve full mantissa precision by scaling signed oracle prices directly into the Q64 fixed-point domain (or an E18 internal oracle representation) before division, or add the truncation bound ceilDiv(BPS_BASE, rawPrice) to the oracle spread so rounding always favors the pool.