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:
- An LP deposits
10,000,000,000 MOG(worth1,058.00 USDCat the authenticated Pyth price$0.0000001058) into a token0-side bin. - A trader executes a swap through the real
PythOracle,AnchoredPriceProvider, andMetricOmmPool.swap(). - The trader buys all
10,000,000,000 MOGfor1,002.1002 USDC, capturing55.8998 USDC(5.28% of the LP’s principal) in a single swap.
Recommended fix
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.