tradi3_ Kurt Landman
Public report archive

Metric T3-002

Price velocity guard can be bypassed by splitting a same-block price move

PriceVelocityGuardExtension.beforeSwap() overwrites lastMidPriceX64 on every accepted swap while keeping a full 1-block movement allowance when blockDiff == 0, allowing multiple same-block oracle updates interleaved with swaps to bypass the per-block velocity cap.

Severity
Medium
Outcome
paid
Competition
Metric
Vulnerability class
oracle / guard bypass
Published

Summary

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

PriceVelocityGuardExtension is documented as capping how fast the oracle price can move between blocks (changeE18^2 <= maxChangePerBlockE18^2 * (1 + blockDiff)). However, beforeSwap() overwrites s.lastMidPriceX64 = midPrice on every accepted swap, and the bound 1 + blockDiff evaluates to 1 even when blockDiff == 0 (multiple swaps within the same block).

Because the scoped Chainlink Data Streams and Pyth Lazer oracle ingestion contracts allow any caller to relay monotonically newer authenticated reports within the same block, a trader can interleave incremental oracle updates with swaps so that each swap resets lastMidPriceX64 and receives a fresh maxChangePerBlockE18 allowance inside a single block.

Root cause

In metric-periphery/contracts/extensions/PriceVelocityGuardExtension.sol (lines 53-70):

/// @notice Caps how fast the provided price can move between blocks, per pool.
/// ...
///        changeE18^2 <= maxChangePerBlockE18^2 * (1 + blockDiff)
function beforeSwap(...) external override {
    ...
    uint128 prevMid = s.lastMidPriceX64;
    uint64 prevBlock = s.lastUpdateBlock;

    s.lastMidPriceX64 = midPrice;
    s.lastUpdateBlock = uint64(block.number);

    uint256 blockDiff = block.number - prevBlock;
    ...
    uint256 allowedSq = uint256(maxChange) * uint256(maxChange) * (1 + blockDiff);
    if (changeSq > allowedSq) revert PriceVelocityExceeded();
}

When a second swap occurs in the same block (block.number == prevBlock):

  1. blockDiff is 0, so 1 + blockDiff == 1, granting another full maxChange step.
  2. prevMid is the midpoint from the previous swap in the same block, not the midpoint at the start of the block.
  3. Each accepted swap ratchet-advances s.lastMidPriceX64, compounding the allowed same-block price movement without bound.

Proof of concept

Against a MetricOmmPool configured with PriceVelocityGuardExtension and a 1% per-block cap (maxChangePerBlockE18 = 0.01e18):

  • Control (single jump): A single 9.37% price update in one block reverts with PriceVelocityExceeded.
  • Bypass (split in same block): Relaying ten sequential 0.9% authenticated updates interleaved with ten exact-input MetricOmmPool.swap() calls in the same block succeeds on every step, reaching the exact same +9.37% final price and extracting 10,340.88 quote tokens (1.034% of the 1,000,000 pool value) from LP inventory.

Anchor same-block velocity checks to the block’s opening reference price (blockStartMidPriceX64) so cumulative movement across all swaps in a single block is measured against one per-block anchor, rather than resetting the anchor on every intra-block swap.