Skip to content
Request an audit

‹ All findings

DeliHookConstantProduct swapping exactOutput and _feeFromOutput is incorrect

MediumBMX·Sherlock · Perpetuals DEX · 2nd September 2025M-5

Summary

DeliHookConstantProduct intends to preserve K in all swaps in order to forward fees to the FeeProcessor, and is achieved for all swap types. However, this is performed with an incorrect calculation when doing exactOutput oneForZero, in which the K is preserved, but the fee charged is incorrect, as will be demonstrated.

Suppose the state of the v2 pool is: Token0 = 1000 Token1 = 1000 FeeCurrency = token0

ExactInput, oneForZero swap (zeroForOne == false)

exactInput = 100 token1 specifiedAmount = 100 Now, let's calculate the _pendingFeeAmount from _calculateOutputFee():

_getAmountOut(false, 100, 1000, 1000, 1e4): feeBasis = 1e6 - 1e4 = 990000 amountInWithFee = 100 990000 = 99000000 numerator = 99000000 1000 = 99000000000 denominator = 1000 * 1e6 + 99000000 = 1099000000 amountOut = 99000000000 / 1099000000 = 90.0818926297 outputWithFee = 90.0818926297

_getAmountOut(false, 100, 1000, 1000, 0): feeBasis = 1e6 - 0 = 1e6 amountInWithFee = 100 1e6 = 1e8 numerator = 1e8 1000 = 1e11 denominator = 1000 * 1e6 + 1e8 = 1100000000 amountOut = 1e11 / 1100000000 = 90.9090909091 outputWithoutFee = 90.9090909091

outputFee = outputWithoutFee - outputWithFee = 90.9090909091 - 90.0818926297 = 0.8271982794 So, _pendingFeeAmount = 0.8271982794

We need to get the unspecified amount, which is the amountOut in this case, and is implemented by the DeliHookConstantProduct, which internally calls getAmountOut. We have already calculated this value above, which is the outputWithFee, 90.0818926297. Hence, unspecifiedAmount = 90.0818926297.

returnDelta becomes (100, -90.0818926297).

Then, in DeliHookConstantProduct::_updateReservesAfterSwap():

specifiedAmount = 100 amountOut = 90.0818926297 delta0 = -90.0818926297 delta1 = 100 delta0 -= 0.8271982794 = -90.9090909091 (_pendingFeeAmount = 0.8271982794) reserves = (1000 - 90.9090909091, 1000 + 100) = (909.090909091, 1100)

And the final K is 909.090909091 * 1100 = 1000000, same as initially, which adds up.

Then, in the Hooks.sol code of Uniswap v4, swapDelta becomes swapDelta = swapDelta - hookDelta = 0 - (hookDeltaUnspecified, hookDeltaSpecified) = 0 - (-90.0818926297, 100) = (+90.0818926297, -100), which comes from returnDelta above, but switched due to the Hooks.sol logic.

As a result, the user will receive +90.0818926297 token0 and pay 100 token1, and the K of the reserves is kept intact, while the FeeProcessor will collect all the fees.

However, it doesn't work correctly for exactOutput, zeroForOne == false, the K will be correct but the fee charged is incorrect.

ExactOutput, oneForZero swap (zeroForOne == false)

exactOutput = 90.0818926297 token0 (amount received from 1st example) specifiedAmount = 90.0818926297 Now, let's calculate the _pendingFeeAmount, which in this case is (amountSpecified uint256(key.fee)) / 1_000_000 = 90.0818926297 1e4 / 1e6 = 0.90081892629, already different from the example above and is incorrect.

We need to get the unspecified amount, which is the amountIn in this case, and is implemented by the DeliHookConstantProduct, which does:

solidity
targetOut = amountSpecified * (1e6 + fee) / 1e6 = 90.0818926297 * (1e6 + 1e4) / 1e6 = 90.982711556.
amountUnspecified = _getAmountIn(false, 90.982711556, 1000, 1000, 0) = (reserveIn * amountOut * 1e6) / ((reserveOut - amountOut) * feeBasis) = (1000 * 90.982711556) / (1000 - 90.982711556) = 100.089088197

returnDelta becomes (-90.0818926297, 100.089088197).

Then, in DeliHookConstantProduct::_updateReservesAfterSwap():

specifiedAmount = 90.0818926297 amountIn = 100.089088197 (same as before) delta0 = -90.0818926297 delta1 = 100.089088197 delta0 -= 0.90081892629 = -90.982711556 (_pendingFeeAmount = 0.90081892629) reserves = (1000 - 90.982711556, 1000 + 100.089088197) = (909.090909091, 1100.0890882)

The final K is 909.017288444 * 1100.0890882 = 1000000, same as initially, which again adds up.

Then, in the Hooks.sol code of Uniswap v4, swapDelta becomes swapDelta = swapDelta - hookDelta = 0 - (hookDeltaSpecified, hookDeltaUnspecified) = 0 - (-90.0818926297, 100.089088197) = (+90.0818926297, -100.089088197), which comes from returnDelta above, but switched due to the Hooks.sol logic.

As a result, the user will receive +90.0818926297 token0 and pay 100.089088197 token1.

Final result

From the 2 analogous examples above, in both the user gets 90.0818926297 token0, but pays 100 token1 in the first example (exactInput) and 100.089088197 in the second example (exactOutput).

In the second example the user is overpaying a significantly higher fee than supposed. Instead of being 0.8271982794, it is 0.90081892629, a very significant error of (0.90081892629 - 0.8271982794) / 0.8271982794 = 8.9 %, high severity.

Root Cause

There are several instances where it needs to be changed and the root cause can be attributed to it (calculation takes place in several steps)

  1. https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L317-L318
  2. https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L576-L577
  3. https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L654

Internal Pre-conditions

None

External Pre-conditions

None

Attack Path

  1. User does an exactOutput, zeroForOne == false swap and pays too many fees.

Impact

User suffers a loss of 8.9%.

PoC

Add the 2 following tests to SwapLifecycle_V2.t.sol:

solidity
function testExactInputSwapZeroForOne() public {
    uint256 inputAmount = 5 ether;
    
    (uint128 r0Before, uint128 r1Before) = hook.getReserves(pid);
    uint256 bmxBefore = bmx.balanceOf(address(this));
    uint256 wbltBefore = wblt.balanceOf(address(this));

    // Perform exact input swap
    poolManager.unlock(abi.encode(address(bmx), inputAmount, true));

    (uint128 r0After, uint128 r1After) = hook.getReserves(pid);

    // Verify token balances
    assertEq(bmxBefore - bmx.balanceOf(address(this)), inputAmount, "BMX spent should match input");
    uint256 outputAmount = wblt.balanceOf(address(this)) - wbltBefore;

    assertEq(outputAmount, 4748297375815592703);
}

function testExactOutputZeroForOne() public {
    uint256 outputAmount = 4748297375815592703;
    
    (uint128 r0Before, uint128 r1Before) = hook.getReserves(pid);
    uint256 bmxBefore = bmx.balanceOf(address(this));
    uint256 wbltBefore = wblt.balanceOf(address(this));

    // Perform exact output swap
    poolManager.unlock(abi.encode(address(bmx), outputAmount, false));

    (uint128 r0After, uint128 r1After) = hook.getReserves(pid);

    // Verify output received
    assertEq(wblt.balanceOf(address(this)) - wbltBefore, outputAmount, "wBLT received should match output");
    uint256 inputAmount = bmxBefore - bmx.balanceOf(address(this));
    
    assertEq(inputAmount, 5000702855111981996); //@audit bigger than above of 5 ether
}

Mitigation

Hinted at the solution in the root cause, to verify the fix, the fees have to match when swapping the same amounts.