Skip to content

feat: add per-hop slippage to single swaps and flip to output/input ratio#516

Open
gretzke wants to merge 11 commits intomainfrom
fix/per-hop-slippage-precision
Open

feat: add per-hop slippage to single swaps and flip to output/input ratio#516
gretzke wants to merge 11 commits intomainfrom
fix/per-hop-slippage-precision

Conversation

@gretzke
Copy link
Contributor

@gretzke gretzke commented Feb 21, 2026

Summary

  • Add minHopPriceX36 field to ExactInputSingleParams and ExactOutputSingleParams for price-based per-hop slippage protection on single swaps -- prevents sandwich attacks when single swaps follow V2/V3 hops with
    positive slippage in the Universal Router

  • Flip the per-hop slippage formula from amountIn * PRECISION / amountOut (revert if > max) to amountOut * PRECISION / amountIn (revert if < min) across all swap types (single + multi-hop)

  • The flipped formula makes 0 a natural disabled value (any real output/input ratio is > 0), removing the need for an explicit != 0 guard on single swaps

  • Add V4TooLittleReceivedPerHopSingle and V4TooMuchRequestedPerHopSingle errors for single swap per-hop slippage violations

  • Update CalldataDecoder minimum length checks for the new struct field

  • Increase PRECISION from 1e18 to 1e36 to handle extreme value ratio pairs (e.g. WBTC/PEPE) where the old precision truncated to 0

  • Rename local price variables to priceX36 for clarity on the scaling factor

  • Replace raw 1e36 literals in tests with a PRECISION constant

    Test plan

  • Revert + success tests for ExactInputSingle per-hop slippage

  • Revert + success tests for ExactOutputSingle per-hop slippage

  • Existing multi-hop per-hop slippage tests updated for flipped formula

  • Extreme ratio regression test with ~1e22 price pool -- asserts 1e18 precision truncates to 0 while 1e36 preserves resolution

  • CalldataDecoder fuzz tests assert new minHopPriceX36 field

  • Full suite passes (486/486) with --isolate

    🤖 Generated with Claude Code

The 1e18 precision caused the price calculation to truncate to 0 for
token pairs with extreme value ratios (e.g. WBTC/PEPE), silently
disabling the slippage check. 1e36 provides sufficient precision
while remaining overflow-safe (uint128_max * 1e36 < uint256_max).

Adds regression test with an extreme price pool verifying the check
still fires for high ratio pairs.
Adds uint256 maxHopSlippage field to ExactInputSingleParams and
ExactOutputSingleParams. When non-zero, enforces a price-based
(amountIn * PRECISION / amountOut) slippage check. This prevents
sandwich attacks when single swaps follow V2/V3 hops with positive
slippage in the Universal Router.

Introduces V4TooLittleReceivedPerHopSingle and
V4TooMuchRequestedPerHopSingle errors for the single swap case.
Changes price formula from amountIn/amountOut (revert if > max) to
amountOut/amountIn (revert if < min). This makes 0 a natural disabled
value for single swap maxHopSlippage since any real output/input ratio
is always > 0, removing the need for an explicit != 0 guard.
@gretzke gretzke changed the title fix: increase per-hop slippage precision from 1e18 to 1e36 feat: add per-hop slippage to single swaps and flip to output/input ratio Feb 21, 2026
gretzke and others added 6 commits February 21, 2026 15:38
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/V4Router.sol Outdated
_swap(params.poolKey, params.zeroForOne, -int256(uint256(amountIn)), params.hookData).toUint128();
if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived(params.amountOutMinimum, amountOut);
if (params.minHopPriceX36 != 0) {
uint256 price = uint256(amountOut) * PRECISION / amountIn;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: name should be priceX36?

uint256 amountIn = 1 ether;
uint256 expectedAmountOut = 992054607780215625;

uint256 expectedPrice = expectedAmountOut * 1e36 / amountIn;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: magic number

Comment on lines +328 to +331
// With output/input formula: price is large for this direction, no truncation concern.
// The precision concern applies in the reverse direction (tiny output, huge input).
uint256 expectedPrice = actualAmountOut * 1e36 / amountIn;
assertGt(expectedPrice, 0, "price should be non-zero");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait maybe im reading this wrong but shouldn't we also check that using 1e18 for precision would result in an expected price of 0?

also we are doing output/input so the comment doesn't make sense to me as it seems like it shouldn't be an issue in the current implementation?

@zhongeric
Copy link

zhongeric commented Mar 13, 2026

Could you update the PR description too plz?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants