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):
blockDiffis0, so1 + blockDiff == 1, granting another fullmaxChangestep.prevMidis the midpoint from the previous swap in the same block, not the midpoint at the start of the block.- 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 withPriceVelocityExceeded. - Bypass (split in same block): Relaying ten sequential
0.9%authenticated updates interleaved with ten exact-inputMetricOmmPool.swap()calls in the same block succeeds on every step, reaching the exact same+9.37%final price and extracting10,340.88quote tokens (1.034%of the1,000,000pool value) from LP inventory.
Recommended fix
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.