<!-- canonical: https://0xsimao.com/findings/bmx-hook-product-output-fee -->

# `DeliHookConstantProduct` swapping `exactOutput` and `_feeFromOutput` is incorrect

Medium · Sherlock · Perpetuals DEX · 2nd September 2025

Finding M-5 of the BMX competition.

- Protocol: https://audits.sherlock.xyz/contests/1154
- Source: https://github.com/sherlock-audit/2025-09-bmx-deli-swap-judging/issues/200

---

### 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()](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L610):

_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](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/base/MultiPoolCustomCurve.sol#L346) amount, which is the amountOut in this case, and is [implemented](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L286) by the DeliHookConstantProduct, which internally calls [getAmountOut](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L311). We have already calculated this value above, which is the outputWithFee, 90.0818926297.
Hence, unspecifiedAmount = 90.0818926297.

[returnDelta](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/base/MultiPoolCustomCurve.sol#L361) becomes (100, -90.0818926297).

Then, in [`DeliHookConstantProduct::_updateReservesAfterSwap()`](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L550):

specifiedAmount = 100
[amountOut](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L561-L562) = 90.0818926297
[delta0](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L568) = -90.0818926297
[delta1](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L569) = 100
[delta0](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L597) -= 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](https://github.com/Uniswap/v4-core/blob/main/src/libraries/Hooks.sol#L312) 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](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L654) 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](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/base/MultiPoolCustomCurve.sol#L346) amount, which is the amountIn in this case, and is [implemented](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L286) by the DeliHookConstantProduct, which [does](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L317-L318):
```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](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/base/MultiPoolCustomCurve.sol#L369) becomes (-90.0818926297, 100.089088197).

Then, in [`DeliHookConstantProduct::_updateReservesAfterSwap()`](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L550):

specifiedAmount = 90.0818926297
[amountIn](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L574-L577) = 100.089088197 (same as before)
[delta0](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L587) = -90.0818926297
[delta1](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L588) = 100.089088197
[delta0](https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DeliHookConstantProduct.sol#L597) -= 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](https://github.com/Uniswap/v4-core/blob/main/src/libraries/Hooks.sol#L312) 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.

---

Related findings:

- [`Leverager::deposit`, does not support multi-hop swaps with `exactOutput`](https://0xsimao.com/findings/yieldoor-deposit-hop-exact-output): Yieldoor
- [`Claimers` can receive less `feePerClaim` than they should if some prizes are already claimed or if reverts because of a reverting hook](https://0xsimao.com/findings/pooltogether-the-prize-layer-for-defi-claimers-fee-reverts-hook): PoolTogether Prize Layer
- [Taker fee is underestimated due to incorrect fee calculation](https://0xsimao.com/findings/1inch-taker-fee-underestimated-calculation): 1inch Fee Extension
- [The incorrect accounting of protocol fee will cause double charging fee and wrong distribution of earnings for variable users](https://0xsimao.com/findings/saffron-lido-vaults-accounting-fee-double-charging): Saffron Lido Vaults
