diff --git a/pkg/pool-hooks/contracts/hooks-quantamm/LPNFT.sol b/pkg/pool-hooks/contracts/hooks-quantamm/LPNFT.sol index a1d4d2ed..8a2b68b5 100644 --- a/pkg/pool-hooks/contracts/hooks-quantamm/LPNFT.sol +++ b/pkg/pool-hooks/contracts/hooks-quantamm/LPNFT.sol @@ -44,6 +44,7 @@ contract LPNFT is ERC721 { previousOwner = super._update(to, tokenId, auth); //_update is called during mint, burn and transfer. This functionality is only for transfer if (to != address(0) && previousOwner != address(0)) { + require(previousOwner != to, "CANNOT_TRANSFER_TO_SELF"); //if transfering the record in the vault needs to be changed to reflect the change in ownership router.afterUpdate(previousOwner, to, tokenId); } diff --git a/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol b/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol index fbf4f56b..ed1a2db2 100644 --- a/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol +++ b/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol @@ -1,7 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity >=0.8.24; +pragma solidity >=0.8.27; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -92,6 +93,7 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { uint64 private constant _MIN_SWAP_FEE_PERCENTAGE = 0.001e16; // 0.001% uint64 private constant _MAX_SWAP_FEE_PERCENTAGE = 10e16; // 10% + uint64 public immutable _MAX_UPLIFT_FEE_PERCENTAGE = 10e16; // 10% /** * @notice A new `UpliftOnlyExampleRegistered` contract has been registered successfully for a given pool. @@ -162,6 +164,20 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { */ error TooManyDeposits(address pool, address depositor); + /** + * @notice To avoid Ddos issues, a single depositor can only deposit 100 times + * @param pool The pool the depositor is attempting to deposit to + * @param depositor The address of the depositor + */ + error TooFastDeposits(address pool, address depositor); + + /** + * @notice To avoid block withdrawal arb, a single withdrawer can only withdraw After a certain blocktime + * @param pool The pool the withdrawer is attempting to withdraw from + * @param withdrawer The address of the withdrawer + */ + error TooFastWithdrawals(address pool, address withdrawer); + /** * @notice Attempted withdrawal of an NFT-associated position by an address that is not the owner. * @param withdrawer The address attempting to withdraw @@ -185,7 +201,7 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { * @param to The address the NFT is being transferred to * @param tokenId The token ID being transferred */ - error TransferUpdateTokenIDInvaid(address from, address to, uint256 tokenId); + error TransferUpdateTokenIDInvalid(address from, address to, uint256 tokenId); modifier onlySelfRouter(address router) { _ensureSelfRouter(router); @@ -216,6 +232,25 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { Router Functions ***************************************************************************/ + /** + * @notice Adds liquidity to a pool proportionally and mints an LP NFT for the depositor. + * @dev This function ensures that deposits are not made too frequently to prevent exploitation. + * It also verifies that the number of deposits does not exceed the allowed limit. + * The liquidity is added proportionally, and the LP token value is calculated using + * registered oracles and update weight runners. + * @param pool The address of the pool to which liquidity is being added. + * @param maxAmountsIn The maximum amounts of tokens to be deposited into the pool. + * @param exactBptAmountOut The exact amount of BPT tokens to be minted for the liquidity addition. + * @param wethIsEth A boolean indicating whether WETH should be treated as ETH. + * @param userData Additional data provided by the user for the liquidity addition. + * @return amountsIn The actual amounts of tokens deposited into the pool. + * @custom:requirements + * - The pool must be registered with the QuantAMM update weight runner. + * - The pool must be approved with oracles that provide the prices. + * @custom:errors + * - `TooManyDeposits`: Thrown if the user has exceeded the maximum allowed deposits for the pool. + * - `TooFastDeposits`: Thrown if deposits are made too frequently within the same block. + */ function addLiquidityProportional( address pool, uint256[] memory maxAmountsIn, @@ -223,9 +258,19 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { bool wethIsEth, bytes memory userData ) external payable saveSender(msg.sender) returns (uint256[] memory amountsIn) { - if (poolsFeeData[pool][msg.sender].length > 100) { + uint256 currentLength = poolsFeeData[pool][msg.sender].length; + if (currentLength >= 100) { revert TooManyDeposits(pool, msg.sender); } + + if (currentLength > 0) { + //There is a possibility that multiple deposits and transfers in the same block can be exploited. + uint256 lastTimestamp = poolsFeeData[pool][msg.sender][currentLength - 1].blockTimestampDeposit; + if (block.timestamp - lastTimestamp <= 1) { + revert TooFastDeposits(pool, msg.sender); + } + } + // Do addLiquidity operation - BPT is minted to this contract. amountsIn = _addLiquidityProportional( pool, @@ -262,21 +307,42 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { nftPool[tokenID] = pool; } + /** + * @notice Removes liquidity from the pool proportionally based on the provided BPT amount. + * @dev This function ensures that only the owner of the deposit can withdraw liquidity. + * The tokens are sent to the caller (`msg.sender`) upon successful withdrawal. + * @param bptAmountIn The amount of BPT (Balancer Pool Tokens) to be redeemed for liquidity. + * @param minAmountsOut An array specifying the minimum amounts of each token expected to be withdrawn. + * @param wethIsEth A boolean indicating whether WETH should be unwrapped to ETH during withdrawal. + * @param pool The address of the pool from which liquidity is being removed. + * @return amountsOut An array containing the amounts of each token withdrawn from the pool. + * @custom:reverts WithdrawalByNonOwner If the caller has no deposits in the specified pool. + * @custom:modifier saveSender Ensures the sender's address is saved for internal tracking. + */ + function removeLiquidityProportional( uint256 bptAmountIn, uint256[] memory minAmountsOut, bool wethIsEth, address pool ) external payable saveSender(msg.sender) returns (uint256[] memory amountsOut) { - uint depositLength = poolsFeeData[pool][msg.sender].length; + address quantAMMAdmin = IUpdateWeightRunner(_updateWeightRunner).getQuantAMMAdmin(); + address sender = address(this); + if (msg.sender != quantAMMAdmin) { + uint depositLength = poolsFeeData[pool][msg.sender].length; - if (depositLength == 0) { - revert WithdrawalByNonOwner(msg.sender, pool, bptAmountIn); + if (depositLength == 0) { + revert WithdrawalByNonOwner(msg.sender, pool, bptAmountIn); + } } + else{ + sender = quantAMMAdmin; + } + // Do removeLiquidity operation - tokens sent to msg.sender. amountsOut = _removeLiquidityProportional( pool, - address(this), + sender, msg.sender, bptAmountIn, minAmountsOut, @@ -328,11 +394,11 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { hookAdjustedAmountCalculatedRaw += hookFee; } - uint256 quantAMMFeeTake = IUpdateWeightRunner(_updateWeightRunner).getQuantAMMUpliftFeeTake(); + uint256 quantAMMFeeTake = IUpdateWeightRunner(_updateWeightRunner).getQuantAMMSwapFeeTake(); uint256 ownerFee = hookFee; if (quantAMMFeeTake > 0) { - uint256 adminFee = hookFee / (1e18 / quantAMMFeeTake); + uint256 adminFee = (hookFee * quantAMMFeeTake) / 1e18; ownerFee = hookFee - adminFee; address quantAMMAdmin = IUpdateWeightRunner(_updateWeightRunner).getQuantAMMAdmin(); _vault.sendTo(feeToken, quantAMMAdmin, adminFee); @@ -340,7 +406,7 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { } if (ownerFee > 0) { - _vault.sendTo(feeToken, address(this), ownerFee); + _vault.sendTo(feeToken, owner(), ownerFee); emit SwapHookFeeCharged(address(this), feeToken, ownerFee); } @@ -360,10 +426,7 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { if (liquidityManagement.enableDonation == false) { revert PoolDoesNotSupportDonation(); } - if (liquidityManagement.disableUnbalancedLiquidity == false) { - revert PoolSupportsUnbalancedLiquidity(); - } - + emit UpliftOnlyExampleRegistered(address(this), pool); return true; @@ -409,6 +472,8 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { //needed to avoid stack too deep error struct AfterRemoveLiquidityData { address pool; + address userAddress; + address quantammAdminAddress; uint256 bptAmountIn; uint256[] amountsOutRaw; uint256[] minAmountsOut; @@ -427,7 +492,29 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { uint256 adminFeePercent; } - /// @inheritdoc BaseHooks + /** + * @notice Hook function triggered after liquidity is removed from a pool. + * @dev This function calculates and applies fees based on the uplift in pool value since the deposit, + * and redistributes accrued fees back to liquidity providers or the admin, depending on the configuration. + * @param router The address of the router initiating the liquidity removal. + * @param pool The address of the pool from which liquidity is being removed. + * @param bptAmountIn The amount of BPT (Balancer Pool Tokens) being burned to remove liquidity. + * @param amountsOutRaw The raw amounts of tokens being withdrawn from the pool. + * @param userData Additional data provided by the user for the liquidity removal operation. + * @return success A boolean indicating whether the operation was successful. + * @return hookAdjustedAmountsOutRaw The adjusted amounts of tokens withdrawn after fees are applied. + * + * @notice Fees are calculated based on the uplift in pool value since the deposit and are capped by + * `_MAX_UPLIFT_FEE_PERCENTAGE`. A minimum withdrawal fee (`minWithdrawalFeeBps`) is always applied. + * @notice Admin fees are redistributed to the admin's address, while other accrued fees are donated back to the pool. + * @notice This function ensures that fees do not exceed the amounts being withdrawn and reverts if this condition is violated. + * + * @dev The function interacts with the Vault to add liquidity back to the pool for fee redistribution. + * @dev The function assumes that liquidity removal is only performed via the Router/Hook itself to ensure proper fee application. + * @dev Emits an `ExitFeeCharged` event when admin fees are charged. + * + * @custom:security Only callable by the Vault and self-router to ensure controlled execution. + */ function onAfterRemoveLiquidity( address router, address pool, @@ -438,135 +525,141 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { uint256[] memory, bytes memory userData ) public override onlySelfRouter(router) returns (bool, uint256[] memory hookAdjustedAmountsOutRaw) { - address userAddress = address(bytes20(userData)); - AfterRemoveLiquidityData memory localData = AfterRemoveLiquidityData({ - pool: pool, - bptAmountIn: bptAmountIn, - amountsOutRaw: amountsOutRaw, - minAmountsOut: new uint256[](amountsOutRaw.length), - accruedFees: new uint256[](amountsOutRaw.length), - accruedQuantAMMFees: new uint256[](amountsOutRaw.length), - currentFee: minWithdrawalFeeBps, - feeAmount: 0, - prices: IUpdateWeightRunner(_updateWeightRunner).getData(pool), - lpTokenDepositValueNow: 0, - lpTokenDepositValueChange: 0, - lpTokenDepositValue: 0, - tokens: new IERC20[](amountsOutRaw.length), - feeDataArrayLength: 0, - amountLeft: 0, - feePercentage: 0, - adminFeePercent: 0 - }); - // We only allow removeLiquidity via the Router/Hook itself so that fee is applied correctly. - hookAdjustedAmountsOutRaw = amountsOutRaw; - - //this rounding faxvours the LP - localData.lpTokenDepositValueNow = getPoolLPTokenValue(localData.prices, pool, MULDIRECTION.MULDOWN); - - FeeData[] storage feeDataArray = poolsFeeData[pool][userAddress]; - localData.feeDataArrayLength = feeDataArray.length; - localData.amountLeft = bptAmountIn; - for (uint256 i = localData.feeDataArrayLength - 1; i >= 0; --i) { - localData.lpTokenDepositValue = feeDataArray[i].lpTokenDepositValue; - - localData.lpTokenDepositValueChange = - (int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) / - int256(localData.lpTokenDepositValue); - - uint256 feePerLP; - // if the pool has increased in value since the deposit, the fee is calculated based on the deposit value - if (localData.lpTokenDepositValueChange > 0) { - feePerLP = - (uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)) / - 10000; - } - // if the pool has decreased in value since the deposit, the fee is calculated based on the base value - see wp - else { - //in most cases this should be a normal swap fee amount. - //there always myst be at least the swap fee amount to avoid deposit/withdraw attack surgace. - feePerLP = (uint256(minWithdrawalFeeBps) * 1e18) / 10000; - } + pool: pool, + bptAmountIn: bptAmountIn, + amountsOutRaw: amountsOutRaw, + minAmountsOut: new uint256[](amountsOutRaw.length), + accruedFees: new uint256[](amountsOutRaw.length), + accruedQuantAMMFees: new uint256[](amountsOutRaw.length), + currentFee: minWithdrawalFeeBps, + feeAmount: 0, + prices: IUpdateWeightRunner(_updateWeightRunner).getData(pool), + lpTokenDepositValueNow: 0, + lpTokenDepositValueChange: 0, + lpTokenDepositValue: 0, + tokens: new IERC20[](amountsOutRaw.length), + feeDataArrayLength: 0, + amountLeft: 0, + feePercentage: 0, + adminFeePercent: IUpdateWeightRunner(_updateWeightRunner).getQuantAMMUpliftFeeTake(), + userAddress: address(bytes20(userData)), + quantammAdminAddress:IUpdateWeightRunner(_updateWeightRunner).getQuantAMMAdmin() + }); + + if (localData.userAddress == localData.quantammAdminAddress) { + return (true, amountsOutRaw); + } else { + + // We only allow removeLiquidity via the Router/Hook itself so that fee is applied correctly. + hookAdjustedAmountsOutRaw = amountsOutRaw; + + // Calculate the current value of the pool in USD, rounding down to favor LPs. + localData.lpTokenDepositValueNow = getPoolLPTokenValue(localData.prices, pool, MULDIRECTION.MULDOWN); + + FeeData[] storage feeDataArray = poolsFeeData[pool][localData.userAddress]; + localData.feeDataArrayLength = feeDataArray.length; + localData.amountLeft = bptAmountIn; + + for (uint256 i = localData.feeDataArrayLength - 1; i >= 0; --i) { + if(feeDataArray[i].blockTimestampDeposit + 60 > block.timestamp){ + revert TooFastWithdrawals(pool, localData.userAddress); + } - // if the deposit is less than the amount left to burn, burn the whole deposit and move on to the next - if (feeDataArray[i].amount <= localData.amountLeft) { - uint256 depositAmount = feeDataArray[i].amount; - localData.feeAmount += (depositAmount * feePerLP); + localData.lpTokenDepositValue = feeDataArray[i].lpTokenDepositValue; + localData.lpTokenDepositValueChange = + ((int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) * 1e18) / + int256(localData.lpTokenDepositValueNow); - localData.amountLeft -= feeDataArray[i].amount; + uint256 feePerLP; - lpNFT.burn(feeDataArray[i].tokenID); + // Calculate fee based on uplift in pool value since deposit, ensuring minimum withdrawal fee is applied. + if (localData.lpTokenDepositValueChange > 0) { + feePerLP = ( + uint256(localData.lpTokenDepositValueChange).mulUp(uint256(feeDataArray[i].upliftFeeBps)) + ); + } - delete feeDataArray[i]; - feeDataArray.pop(); + if (feePerLP < uint256(minWithdrawalFeeBps)) { + feePerLP = uint256(minWithdrawalFeeBps); + } + + if (feePerLP > uint256(_MAX_UPLIFT_FEE_PERCENTAGE)) { + feePerLP = uint256(_MAX_UPLIFT_FEE_PERCENTAGE); + } + + // Burn deposits sequentially (FILO) until the requested amount is fully withdrawn. + if (feeDataArray[i].amount <= localData.amountLeft) { + uint256 withdrawAmount = feeDataArray[i].amount; - if (localData.amountLeft == 0) { + localData.feeAmount += withdrawAmount.mulDown(feePerLP); + localData.amountLeft -= feeDataArray[i].amount; + + lpNFT.burn(feeDataArray[i].tokenID); + + delete feeDataArray[i]; + feeDataArray.pop(); + + if (localData.amountLeft == 0) { + break; + } + } else { + feeDataArray[i].amount -= localData.amountLeft; + localData.feeAmount += localData.amountLeft.mulDown(feePerLP); break; } - } else { - feeDataArray[i].amount -= localData.amountLeft; - localData.feeAmount += (feePerLP * localData.amountLeft); - break; } - } - localData.feePercentage = (localData.feeAmount) / bptAmountIn; + localData.feePercentage = localData.feeAmount.divDown(bptAmountIn); + hookAdjustedAmountsOutRaw = localData.amountsOutRaw; + localData.tokens = _vault.getPoolTokens(localData.pool); - hookAdjustedAmountsOutRaw = localData.amountsOutRaw; - localData.tokens = _vault.getPoolTokens(localData.pool); + localData.adminFeePercent = IUpdateWeightRunner(_updateWeightRunner).getQuantAMMUpliftFeeTake(); - localData.adminFeePercent = IUpdateWeightRunner(_updateWeightRunner).getQuantAMMUpliftFeeTake(); + // Charge fees proportional to the `amountOut` of each token. + for (uint256 i = 0; i < localData.amountsOutRaw.length; i++) { + uint256 exitFee = localData.amountsOutRaw[i].mulUp(localData.feePercentage); - // Charge fees proportional to the `amountOut` of each token. - for (uint256 i = 0; i < localData.amountsOutRaw.length; i++) { - uint256 exitFee = localData.amountsOutRaw[i].mulDown(localData.feePercentage); + if (localData.adminFeePercent > 0) { + localData.accruedQuantAMMFees[i] = exitFee.mulUp(localData.adminFeePercent); + } - if (localData.adminFeePercent > 0) { - localData.accruedQuantAMMFees[i] = exitFee.mulDown(localData.adminFeePercent); - } + localData.accruedFees[i] = exitFee - localData.accruedQuantAMMFees[i]; + if (localData.accruedFees[i] + localData.accruedQuantAMMFees[i] > localData.amountsOutRaw[i]) { + // Ensure fees do not exceed the amounts being withdrawn. + revert("Accrued fees exceed amounts out"); + } - localData.accruedFees[i] = exitFee - localData.accruedQuantAMMFees[i]; - hookAdjustedAmountsOutRaw[i] -= exitFee; - // Fees don't need to be transferred to the hook, because donation will redeposit them in the Vault. - // In effect, we will transfer a reduced amount of tokensOut to the caller, and leave the remainder - // in the pool balance. - } + if(localData.accruedQuantAMMFees[i] > 0){ + _vault.sendTo(localData.tokens[i], localData.quantammAdminAddress, localData.accruedQuantAMMFees[i]); + } - if (localData.adminFeePercent > 0) { - _vault.addLiquidity( - AddLiquidityParams({ - pool: localData.pool, - to: IUpdateWeightRunner(_updateWeightRunner).getQuantAMMAdmin(), - maxAmountsIn: localData.accruedQuantAMMFees, - minBptAmountOut: localData.feeAmount.mulDown(localData.adminFeePercent) / 1e18, - kind: AddLiquidityKind.PROPORTIONAL, - userData: bytes("") - }) - ); - emit ExitFeeCharged( - userAddress, - localData.pool, - IERC20(localData.pool), - localData.feeAmount.mulDown(localData.adminFeePercent) / 1e18 - ); - } + emit ExitFeeCharged( + localData.userAddress, + localData.pool, + localData.tokens[i], + localData.accruedQuantAMMFees[i] + ); - if (localData.adminFeePercent != 1e18) { - // Donates accrued fees back to LPs. - _vault.addLiquidity( - AddLiquidityParams({ - pool: localData.pool, - to: msg.sender, // It would mint BPTs to router, but it's a donation so no BPT is minted - maxAmountsIn: localData.accruedFees, // Donate all accrued fees back to the pool (i.e. to the LPs) - minBptAmountOut: 0, // Donation does not return BPTs, any number above 0 will revert - kind: AddLiquidityKind.DONATION, - userData: bytes("") // User data is not used by donation, so we can set it to an empty string - }) - ); - } + hookAdjustedAmountsOutRaw[i] = localData.amountsOutRaw[i] - exitFee; + } + + if (localData.adminFeePercent != 1e18) { + // Donate accrued fees back to LPs. + _vault.addLiquidity( + AddLiquidityParams({ + pool: localData.pool, + to: localData.userAddress, // It would mint BPTs to router, but it's a donation so no BPT is minted + maxAmountsIn: localData.accruedFees, // Donate all accrued fees back to the pool (i.e. to the LPs) + minBptAmountOut: 0, // Donation does not return BPTs, any number above 0 will revert + kind: AddLiquidityKind.DONATION, + userData: bytes("") // User data is not used by donation, so we can set it to an empty string + }) + ); + } - return (true, hookAdjustedAmountsOutRaw); + return (true, hookAdjustedAmountsOutRaw); + } } /// @param _from the owner to transfer from @@ -581,7 +674,11 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { address poolAddress = nftPool[_tokenID]; if (poolAddress == address(0)) { - revert TransferUpdateTokenIDInvaid(_from, _to, _tokenID); + revert TransferUpdateTokenIDInvalid(_from, _to, _tokenID); + } + + if (poolsFeeData[poolAddress][_to].length >= 100) { + revert TooManyDeposits(poolAddress, _to); } int256[] memory prices = IUpdateWeightRunner(_updateWeightRunner).getData(poolAddress); @@ -607,8 +704,7 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { // Update the deposit value to the current value of the pool in base currency (e.g. USD) and the block index to the current block number //vault.transferLPTokens(_from, _to, feeDataArray[i].amount); feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow; - feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number); - feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps; + feeDataArray[tokenIdIndex].blockTimestampDeposit = uint40(block.timestamp); //actual transfer not a afterTokenTransfer caused by a burn poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]); diff --git a/pkg/pool-hooks/foundry.toml b/pkg/pool-hooks/foundry.toml index c56461b8..9806f7e2 100755 --- a/pkg/pool-hooks/foundry.toml +++ b/pkg/pool-hooks/foundry.toml @@ -25,7 +25,7 @@ remappings = [ ] optimizer = true optimizer_runs = 999 -solc_version = '0.8.26' +solc_version = '0.8.27' auto_detect_solc = false evm_version = 'cancun' ignored_error_codes = [2394, 5574, 3860] # Transient storage, code size diff --git a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol index 07751df3..65a18c3a 100644 --- a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol +++ b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.24; import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -16,6 +18,8 @@ import { } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultAdmin.sol"; +import { IVaultExplorer } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExplorer.sol"; +import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; import { IVaultMock } from "@balancer-labs/v3-interfaces/contracts/test/IVaultMock.sol"; import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; @@ -66,7 +70,6 @@ contract UpliftOnlyExampleTest is BaseVaultTest { UpliftOnlyExample internal upliftOnlyRouter; - // Overrides `setUp` to include a deployment for UpliftOnlyExample. function setUp() public virtual override { BaseTest.setUp(); (address ownerLocal, address addr1Local, address addr2Local) = (vm.addr(1), vm.addr(2), vm.addr(3)); @@ -103,8 +106,8 @@ contract UpliftOnlyExampleTest is BaseVaultTest { IVault(address(vault)), weth, permit2, - 200, - 5, + 200e14, + 5e14, address(updateWeightRunner), "Uplift LiquidityPosition v1", "Uplift LiquidityPosition v1", @@ -113,11 +116,9 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.stopPrank(); vm.label(address(upliftOnlyRouter), "upliftOnlyRouter"); - // Here the Router is also the hook. poolHooksContract = address(upliftOnlyRouter); (pool, ) = createPool(); - // Approve vault allowances. for (uint256 i = 0; i < users.length; ++i) { address user = users[i]; vm.startPrank(user); @@ -210,7 +211,6 @@ contract UpliftOnlyExampleTest is BaseVaultTest { BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - // Bob sends correct lp tokens assertEq( balancesBefore.bobTokens[daiIdx] - balancesAfter.bobTokens[daiIdx], amountsIn[daiIdx], @@ -221,7 +221,7 @@ contract UpliftOnlyExampleTest is BaseVaultTest { amountsIn[usdcIdx], "bob's USDC amount is wrong" ); - // Router should set correct lp data + uint256 expectedTokenId = 1; assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "deposit length incorrect"); @@ -236,99 +236,10 @@ contract UpliftOnlyExampleTest is BaseVaultTest { 500000000000000000, "should match sum(amount * price)" ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee"); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200e14, "fee"); assertEq(upliftOnlyRouter.nftPool(expectedTokenId), pool, "pool mapping is wrong"); - // Router should receive BPT instead of bob, he gets the NFT - assertEq( - BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), - bptAmount, - "UpliftOnlyRouter should hold BPT" - ); - assertEq(balancesAfter.bobBpt, 0, "bob should not hold any BPT"); - } - - function testAddLiquidityMultipleDeposits() public { - BaseVaultTest.Balances memory balancesBefore = getBalances(bob); - uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); - vm.prank(bob); - uint256[] memory amountsInFirst = upliftOnlyRouter.addLiquidityProportional( - pool, - maxAmountsIn, - bptAmount / 2, - false, - bytes("") - ); - vm.stopPrank(); - - int256[] memory prices = new int256[](2); - for (uint256 i = 0; i < 2; ++i) { - prices[i] = (int256(i) * 1e18) / 2; - } - updateWeightRunner.setMockPrices(pool, prices); - - skip(5 days); - - vm.prank(bob); - uint256[] memory amountsInSecond = upliftOnlyRouter.addLiquidityProportional( - pool, - maxAmountsIn, - bptAmount / 2, - false, - bytes("") - ); - vm.stopPrank(); - - uint256[] memory amountsIn = new uint256[](2); - for (uint256 i = 0; i < 2; ++i) { - amountsIn[i] = amountsInFirst[i] + amountsInSecond[i]; - } - - BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - - // Bob sends correct lp tokens - assertEq( - balancesBefore.bobTokens[daiIdx] - balancesAfter.bobTokens[daiIdx], - amountsIn[daiIdx], - "bob's DAI amount is wrong" - ); - assertEq( - balancesBefore.bobTokens[usdcIdx] - balancesAfter.bobTokens[usdcIdx], - amountsIn[usdcIdx], - "bob's USDC amount is wrong" - ); - - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 2, "deposit length incorrect"); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].amount, bptAmount / 2, "bptAmount incorrect"); - assertEq( - upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].blockTimestampDeposit, - 1682899200, - "blockTimestampDeposit incorrect" - ); - assertEq( - upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].lpTokenDepositValue, - 500000000000000000, - "should match sum(amount * price)" - ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee"); - - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[1].amount, bptAmount / 2, "bptAmount incorrect"); - assertEq( - upliftOnlyRouter.getUserPoolFeeData(pool, bob)[1].blockTimestampDeposit, - 1683331200, - "blockTimestampDeposit incorrect" - ); - assertEq( - upliftOnlyRouter.getUserPoolFeeData(pool, bob)[1].lpTokenDepositValue, - 250000000000000000, - "should match sum(amount * price)" - ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[1].upliftFeeBps, 200, "fee"); - - assertEq(upliftOnlyRouter.nftPool(1), pool, "pool mapping is wrong"); - assertEq(upliftOnlyRouter.nftPool(2), pool, "pool mapping is wrong"); - // Router should receive BPT instead of bob, he gets the NFT assertEq( BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), bptAmount, @@ -342,7 +253,7 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.startPrank(bob); uint256 bptAmountDeposit = bptAmount / 150; for (uint256 i = 0; i < 150; i++) { - if (i == 101) { + if (i == 100) { vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.TooManyDeposits.selector, pool, bob)); upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmountDeposit, false, bytes("")); break; @@ -355,73 +266,6 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.stopPrank(); } - function testTransferDepositsAtRandom(uint256 seed, uint256 depositLength) public { - uint256 depositBound = bound(depositLength, 1, 10); - /** - * This can be changed to the max 98 however it takes some time! - * uint256 depositBound = bound(depositLength, 1, 98); - * [PASS] testTransferDepositsAtRandom(uint256,uint256) (runs: 10002, μ: 119097137, ~: 78857000) - Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1233.99s (1233.98s CPU time) - - Ran 1 test suite in 1234.00s (1233.99s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) - * - */ - uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); - vm.startPrank(bob); - uint256 bptAmountDeposit = bptAmount / 150; - uint256[] memory tokenIndexArray = new uint256[](depositBound); - for (uint256 i = 0; i < depositBound; i++) { - tokenIndexArray[i] = i + 1; - upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmountDeposit, false, bytes("")); - skip(1 days); - } - vm.stopPrank(); - - // Shuffle the array using the seed - uint[] memory shuffledArray = shuffle(tokenIndexArray, seed); - - LPNFT lpNft = upliftOnlyRouter.lpNFT(); - - for (uint256 i = 0; i < depositBound; i++) { - vm.startPrank(bob); - - lpNft.transferFrom(bob, alice, shuffledArray[i]); - UpliftOnlyExample.FeeData[] memory aliceFees = upliftOnlyRouter.getUserPoolFeeData(pool, alice); - UpliftOnlyExample.FeeData[] memory bobFees = upliftOnlyRouter.getUserPoolFeeData(pool, bob); - - assertEq(aliceFees.length, i + 1, "alice should have all transfers"); - assertEq( - aliceFees[aliceFees.length - 1].tokenID, - shuffledArray[i], - "last transferred tokenId should match" - ); - - assertEq(bobFees.length, depositBound - (i + 1), "bob should have all transferred last"); - - uint[] memory orderedArrayWithoutShuffled = new uint[](depositBound - (i + 1)); - uint lastPopulatedIndex = 0; - for (uint256 j = 1; j <= depositBound; j++) { - bool inPreviousShuffled = false; - for (uint256 k = 0; k < i + 1; k++) { - if (shuffledArray[k] == j) { - inPreviousShuffled = true; - break; - } - } - if (!inPreviousShuffled) { - orderedArrayWithoutShuffled[lastPopulatedIndex] = j; - lastPopulatedIndex++; - } - } - - for (uint256 j = 0; j < bobFees.length; j++) { - assertEq(bobFees[j].tokenID, orderedArrayWithoutShuffled[j], "bob should have ordered tokenID"); - } - - vm.stopPrank(); - } - } - //Function to generate a shuffled array of unique uints between 0 and 10 function shuffle(uint[] memory array, uint seed) internal pure returns (uint[] memory) { uint length = array.length; @@ -433,14 +277,37 @@ contract UpliftOnlyExampleTest is BaseVaultTest { return array; } + struct nopriceChangeLocals { + uint256[] maxAmountsIn; + uint256[] minAmountsOut; + BaseVaultTest.Balances balancesBefore; + BaseVaultTest.Balances balancesAfter; + uint256 amountOut; + uint64 exitFeePercentage; + uint256 hookFee; + uint256 adminFeePercent; + uint256 adminPartPerToken; + uint256 lpDonationPerToken; + uint256 bobReceivesPerToken; + uint256 netPoolDecreasePerToken; + uint256 nftTokenId; + } + function testRemoveLiquidityNoPriceChange() public { - // Add liquidity so bob has BPT to remove liquidity. - uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + nopriceChangeLocals memory v; + // 1) Bob adds liquidity so he has BPT to remove later. + v.maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); vm.prank(bob); - upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes("")); + upliftOnlyRouter.addLiquidityProportional(pool, v.maxAmountsIn, bptAmount, false, bytes("")); vm.stopPrank(); + // Hand hook ownership to the hook contract (as in your setup). + vm.prank(owner); + UpliftOnlyExample(payable(poolHooksContract)).transferOwnership(poolHooksContract); + vm.stopPrank(); + + // Sanity checks on stored deposit data. assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "bptAmount mapping should be 1"); assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].amount, bptAmount, "bptAmount mapping should be 0"); assertEq( @@ -453,88 +320,159 @@ contract UpliftOnlyExampleTest is BaseVaultTest { 500000000000000000, "should match sum(amount * price)" ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee"); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200e14, "fee"); - uint256 nftTokenId = 0; - uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + v.nftTokenId = 0; + v.minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); - BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + v.balancesBefore = getBalances(bob); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + // 2) Bob removes all his BPT proportionally (no price change case). vm.startPrank(bob); - upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool); + upliftOnlyRouter.removeLiquidityProportional(bptAmount, v.minAmountsOut, false, pool); vm.stopPrank(); - BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - uint256 feeAmountAmountPercent = ((bptAmount / 2) * - ((uint256(upliftOnlyRouter.minWithdrawalFeeBps()) * 1e18) / 10000)) / ((bptAmount / 2)); - uint256 amountOut = (bptAmount / 2).mulDown((1e18 - feeAmountAmountPercent)); + v.balancesAfter = getBalances(bob); + + // === 3) Fee math with no price change === + // Proportional 2-token pool → raw amount out per token equals bptAmount / 2 + v.amountOut = bptAmount / 2; // = 1e21 in your traces + + // With zero uplift, the minimum withdrawal fee applies. + v.exitFeePercentage = upliftOnlyRouter.minWithdrawalFeeBps(); // 5e14 (0.05%) + v.hookFee = v.amountOut.mulDown(v.exitFeePercentage); // 1e21 * 5e14 / 1e18 = 5e17 per token + + // Split of the hook fee: 50% admin (sent out), 50% donation (kept in pool) — no BPT minted for donation. + v.adminFeePercent = updateWeightRunner.getQuantAMMUpliftFeeTake(); // 0.5e18 + v.adminPartPerToken = v.hookFee.mulUp(v.adminFeePercent); // 2.5e17 per token + v.lpDonationPerToken = v.hookFee - v.adminPartPerToken; // 2.5e17 per token + + // What Bob actually receives: + v.bobReceivesPerToken = v.amountOut - v.hookFee; // 9.995e20 per token + + // Net change to Pool/Vault per token: + // remove amountOut (1e21) but donate lpDonation back (2.5e17) → net decrease = 9.9975e20 + v.netPoolDecreasePerToken = v.amountOut - v.lpDonationPerToken; // 9.9975e20 - // Bob gets original liquidity with no fee applied because of full decay. + // === 4) Assertions === + + // Bob receives the adjusted amount (after full fee). assertEq( - balancesAfter.bobTokens[daiIdx] - balancesBefore.bobTokens[daiIdx], - amountOut, + v.balancesAfter.bobTokens[daiIdx] - v.balancesBefore.bobTokens[daiIdx], + v.bobReceivesPerToken, "bob's DAI amount is wrong" ); assertEq( - balancesAfter.bobTokens[usdcIdx] - balancesBefore.bobTokens[usdcIdx], - amountOut, + v.balancesAfter.bobTokens[usdcIdx] - v.balancesBefore.bobTokens[usdcIdx], + v.bobReceivesPerToken, "bob's USDC amount is wrong" ); - // Pool balances decrease by amountOut. + // Pool balances decrease by the NET amount (raw out minus donation back to pool). assertEq( - balancesBefore.poolTokens[daiIdx] - balancesAfter.poolTokens[daiIdx], - amountOut, + v.balancesBefore.poolTokens[daiIdx] - v.balancesAfter.poolTokens[daiIdx], + v.netPoolDecreasePerToken, "Pool's DAI amount is wrong" ); assertEq( - balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], - amountOut, + v.balancesBefore.poolTokens[usdcIdx] - v.balancesAfter.poolTokens[usdcIdx], + v.netPoolDecreasePerToken, "Pool's USDC amount is wrong" ); - //As the bpt value taken in fees is readded to the pool under the router address, the pool supply should remain the same - assertEq(balancesBefore.poolSupply - balancesAfter.poolSupply, bptAmount, "BPT supply amount is wrong"); + // The entire bptAmount is burned on exit; donation mints ZERO BPT → supply drops by bptAmount. + assertEq(v.balancesBefore.poolSupply - v.balancesAfter.poolSupply, bptAmount, "BPT supply amount is wrong"); - // Same happens with Vault balances: decrease by amountOut. + // Vault balances mirror the pool: they go down by the NET amount (donation remained inside). assertEq( - balancesBefore.vaultTokens[daiIdx] - balancesAfter.vaultTokens[daiIdx], - amountOut, + v.balancesBefore.vaultTokens[daiIdx] - v.balancesAfter.vaultTokens[daiIdx], + v.netPoolDecreasePerToken, "Vault's DAI amount is wrong" ); assertEq( - balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], - amountOut, + v.balancesBefore.vaultTokens[usdcIdx] - v.balancesAfter.vaultTokens[usdcIdx], + v.netPoolDecreasePerToken, "Vault's USDC amount is wrong" ); // Hook balances remain unchanged. - assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); - assertEq(balancesBefore.hookTokens[usdcIdx], balancesAfter.hookTokens[usdcIdx], "Hook's USDC amount is wrong"); + assertEq(v.balancesBefore.hookTokens[daiIdx], v.balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); + assertEq( + v.balancesBefore.hookTokens[usdcIdx], + v.balancesAfter.hookTokens[usdcIdx], + "Hook's USDC amount is wrong" + ); - // Router should set all lp data to 0. - //User has extracted deposit, now deposit was deleted and popped from the mapping + // Router should clear all lp data and free mappings. assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.bptAmount(nftTokenId), 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.startTime(nftTokenId), 0, "startTime mapping should be 0"); - - assertEq(upliftOnlyRouter.nftPool(nftTokenId), address(0), "pool mapping should be 0"); + assertEq(upliftOnlyRouter.nftPool(v.nftTokenId), address(0), "pool mapping should be 0"); + // No stray BPT anywhere. assertEq( BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), 0, "upliftOnlyRouter should hold no BPT" ); - assertEq(balancesAfter.bobBpt, 0, "bob should not hold any BPT"); + assertEq(v.balancesAfter.bobBpt, 0, "bob should not hold any BPT"); + } + + function _grossTokenOut( + uint256 poolReservesBefore, + uint256 poolSupplyBefore, + uint256 bptIn + ) internal pure returns (uint256) { + return (poolReservesBefore * bptIn) / poolSupplyBefore; + } + + /// @dev Net amount after charging `feeBps` (0 … 10_000). + function _netAfterFee(uint256 grossAmount, uint256 feeBps) internal pure returns (uint256) { + return grossAmount - (grossAmount * feeBps) / 10_000; + } + + function _approveAllUsers() internal { + for (uint256 i; i < users.length; ++i) { + vm.startPrank(users[i]); + approveForSender(); + vm.stopPrank(); + } + if (pool != address(0)) { + approveForPool(IERC20(pool)); + } + } + + struct negativePriceChangeLocals { + uint256[] maxAmountsIn; + int256[] prices; + uint256 nftTokenId; + uint256[] minAmountsOut; + BaseVaultTest.Balances balancesBefore; + BaseVaultTest.Balances balancesAfter; + uint256 amountOut; + uint64 exitFeePercentage; + uint256 hookFee; + uint256 adminFeePercent; + uint256 adminPartPerToken; + uint256 lpDonationPerToken; + uint256 bobReceivesPerToken; + uint256 netPoolDecreasePerToken; } function testRemoveLiquidityNegativePriceChange() public { - // Add liquidity so bob has BPT to remove liquidity. - uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + negativePriceChangeLocals memory v; + + // 1) Bob adds liquidity so he has BPT to remove later. + v.maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); vm.prank(bob); - upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes("")); + upliftOnlyRouter.addLiquidityProportional(pool, v.maxAmountsIn, bptAmount, false, bytes("")); + vm.stopPrank(); + + // Hand hook ownership to the hook contract (as in your setup). + vm.prank(owner); + UpliftOnlyExample(payable(poolHooksContract)).transferOwnership(poolHooksContract); vm.stopPrank(); + // Sanity checks on stored deposit data. assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "bptAmount mapping should be 1"); assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].amount, bptAmount, "bptAmount mapping should be 0"); assertEq( @@ -547,92 +485,142 @@ contract UpliftOnlyExampleTest is BaseVaultTest { 500000000000000000, "should match sum(amount * price)" ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee"); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200e14, "fee"); - int256[] memory prices = new int256[](tokens.length); + // 2) Push prices DOWN so there is a negative uplift. + // With negative uplift, the contract applies minimum withdrawal fee (minWithdrawalFeeBps). + v.prices = new int256[](tokens.length); for (uint256 i = 0; i < tokens.length; ++i) { - prices[i] = int256(i) / 2; + v.prices[i] = (int256(i) * 1e18) / 2; // halve prices } - updateWeightRunner.setMockPrices(pool, prices); - - uint256 nftTokenId = 0; - uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + updateWeightRunner.setMockPrices(pool, v.prices); - BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + v.nftTokenId = 0; + v.minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + v.balancesBefore = getBalances(bob); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + // 3) Bob removes all his BPT proportionally. vm.startPrank(bob); - upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool); + upliftOnlyRouter.removeLiquidityProportional(bptAmount, v.minAmountsOut, false, pool); vm.stopPrank(); - BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - uint256 feeAmountAmountPercent = ((bptAmount / 2) * - ((uint256(upliftOnlyRouter.minWithdrawalFeeBps()) * 1e18) / 10000)) / ((bptAmount / 2)); - uint256 amountOut = (bptAmount / 2).mulDown((1e18 - feeAmountAmountPercent)); + v.balancesAfter = getBalances(bob); + + // === 4) Fee math with your setup === + // amountOutRaw per token for a symmetric 2-token pool = bptAmount / 2 + v.amountOut = bptAmount / 2; // = 1e21 in your logs + + // With negative uplift, fee% = minWithdrawalFeeBps (5e14 = 0.05%). + v.exitFeePercentage = upliftOnlyRouter.minWithdrawalFeeBps(); // 5e14 + v.hookFee = v.amountOut.mulDown(v.exitFeePercentage); // 1e21 * 5e14 / 1e18 = 5e17 per token + + // Split fee: 50% admin, 50% donation (per your setup). + v.adminFeePercent = updateWeightRunner.getQuantAMMUpliftFeeTake(); // 0.5e18 + v.adminPartPerToken = v.hookFee.mulUp(v.adminFeePercent); // 2.5e17 per token + v.lpDonationPerToken = v.hookFee - v.adminPartPerToken; // 2.5e17 per token + + // Bob actually receives: + v.bobReceivesPerToken = v.amountOut - v.hookFee; // 9.995e20 per token - // Bob gets original liquidity with no fee applied because of full decay. + // Pool/Vault net decrease per token: + // remove amountOut (1e21) but immediately donate lpDonation (2.5e17) back → net decrease = 9.9975e20 + v.netPoolDecreasePerToken = v.amountOut - v.lpDonationPerToken; // 9.9975e20 + + // === 5) Assertions === + + // Bob receives the adjusted amount (after full fee). assertEq( - balancesAfter.bobTokens[daiIdx] - balancesBefore.bobTokens[daiIdx], - amountOut, + v.balancesAfter.bobTokens[daiIdx] - v.balancesBefore.bobTokens[daiIdx], + v.bobReceivesPerToken, "bob's DAI amount is wrong" ); assertEq( - balancesAfter.bobTokens[usdcIdx] - balancesBefore.bobTokens[usdcIdx], - amountOut, + v.balancesAfter.bobTokens[usdcIdx] - v.balancesBefore.bobTokens[usdcIdx], + v.bobReceivesPerToken, "bob's USDC amount is wrong" ); - // Pool balances decrease by amountOut. + // Pool balances decrease by the NET amount (raw out minus donation). assertEq( - balancesBefore.poolTokens[daiIdx] - balancesAfter.poolTokens[daiIdx], - amountOut, + v.balancesBefore.poolTokens[daiIdx] - v.balancesAfter.poolTokens[daiIdx], + v.netPoolDecreasePerToken, "Pool's DAI amount is wrong" ); assertEq( - balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], - amountOut, + v.balancesBefore.poolTokens[usdcIdx] - v.balancesAfter.poolTokens[usdcIdx], + v.netPoolDecreasePerToken, "Pool's USDC amount is wrong" ); - //As the bpt value taken in fees is readded to the pool under the router address, the pool supply should remain the same - assertEq(balancesBefore.poolSupply - balancesAfter.poolSupply, bptAmount, "BPT supply amount is wrong"); + // Entire bptAmount is burned on exit; donation mints ZERO BPT. + assertEq(v.balancesBefore.poolSupply - v.balancesAfter.poolSupply, bptAmount, "BPT supply amount is wrong"); - // Same happens with Vault balances: decrease by amountOut. + // Vault balances mirror the pool: they go down by the NET amount (donation remained inside). assertEq( - balancesBefore.vaultTokens[daiIdx] - balancesAfter.vaultTokens[daiIdx], - amountOut, + v.balancesBefore.vaultTokens[daiIdx] - v.balancesAfter.vaultTokens[daiIdx], + v.netPoolDecreasePerToken, "Vault's DAI amount is wrong" ); assertEq( - balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], - amountOut, + v.balancesBefore.vaultTokens[usdcIdx] - v.balancesAfter.vaultTokens[usdcIdx], + v.netPoolDecreasePerToken, "Vault's USDC amount is wrong" ); // Hook balances remain unchanged. - assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); - assertEq(balancesBefore.hookTokens[usdcIdx], balancesAfter.hookTokens[usdcIdx], "Hook's USDC amount is wrong"); + assertEq(v.balancesBefore.hookTokens[daiIdx], v.balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); + assertEq( + v.balancesBefore.hookTokens[usdcIdx], + v.balancesAfter.hookTokens[usdcIdx], + "Hook's USDC amount is wrong" + ); - // Router should set all lp data to 0. - //User has extracted deposit, now deposit was deleted and popped from the mapping + // Router should clear all lp data (FILO burn and delete). assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.bptAmount(nftTokenId), 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.startTime(nftTokenId), 0, "startTime mapping should be 0"); - - assertEq(upliftOnlyRouter.nftPool(nftTokenId), address(0), "pool mapping should be 0"); + assertEq(upliftOnlyRouter.nftPool(v.nftTokenId), address(0), "pool mapping should be 0"); + // No stray BPT anywhere. assertEq( BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), 0, "upliftOnlyRouter should hold no BPT" ); - assertEq(balancesAfter.bobBpt, 0, "bob should not hold any BPT"); + assertEq(v.balancesAfter.bobBpt, 0, "bob should not hold any BPT"); + } + + struct doublePositivePriceLocals { + uint256[] maxAmountsIn; + int256[] prices; + uint256 nftTokenId; + uint256[] minAmountsOut; + BaseVaultTest.Balances balancesBefore; + BaseVaultTest.Balances balancesAfter; + uint256 valueAtDeposit; + uint256 valueNow; + uint256 upliftRatio; + uint256 feePercentage; + uint256 amountOutRawPerToken; + uint256 hookFeePerToken; + uint256 adminFeePercent; + uint256 adminPartPerToken; + uint256 lpDonationPerToken; + uint256 bobReceivesPerToken; + uint256 netPoolDecreasePerToken; + address admin; } function testRemoveLiquidityDoublePositivePriceChange() public { + doublePositivePriceLocals memory v; + // Add liquidity so bob has BPT to remove liquidity. - uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + v.maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); vm.prank(bob); - upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes("")); + upliftOnlyRouter.addLiquidityProportional(pool, v.maxAmountsIn, bptAmount, false, bytes("")); + vm.stopPrank(); + + vm.prank(owner); + UpliftOnlyExample(payable(poolHooksContract)).transferOwnership(poolHooksContract); vm.stopPrank(); assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "bptAmount mapping should be 1"); @@ -647,94 +635,128 @@ contract UpliftOnlyExampleTest is BaseVaultTest { 500000000000000000, "should match sum(amount * price)" ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee"); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200e14, "fee"); - int256[] memory prices = new int256[](tokens.length); + // Push prices up so there is positive uplift (value doubles from 0.5 -> 1.0). + v.prices = new int256[](tokens.length); for (uint256 i = 0; i < tokens.length; ++i) { - prices[i] = int256(i) * 2e18; + v.prices[i] = int256(i) * 2e18; } - updateWeightRunner.setMockPrices(pool, prices); - - uint256 nftTokenId = 0; - uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + updateWeightRunner.setMockPrices(pool, v.prices); - BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + v.nftTokenId = 0; + v.minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + v.balancesBefore = getBalances(bob); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc vm.startPrank(bob); - upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool); + upliftOnlyRouter.removeLiquidityProportional(bptAmount, v.minAmountsOut, false, pool); vm.stopPrank(); - BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - uint256 feeAmountAmountPercent = ((bptAmount / 2) * - ((uint256(upliftOnlyRouter.upliftFeeBps()) * 1e18) / 10000)) / ((bptAmount / 2)); + v.balancesAfter = getBalances(bob); + + // === Fee math (all 18 dp) === + // Deposit value used at entry: + v.valueAtDeposit = 0.5e18; + // Current LP value (after price update): + v.valueNow = 1e18; + + // Uplift ratio = (now - deposit) / now + v.upliftRatio = ((v.valueNow - v.valueAtDeposit) * 1e18) / v.valueNow; + + // Effective fee% + v.feePercentage = v.upliftRatio.mulDown(uint256(upliftOnlyRouter.upliftFeeBps())); - /* - Bob has doubled his value. - Uplift fee is taken on only the uplift. - Given each BPT is worth double now, the fee is 2% of the original value. - Bob has 1000e18 in BPT, so the fee is 20e18. - Bob should get 980e18 in DAI and USDC. - */ + // Each token pays out bptAmount/2 on a symmetric pool. + v.amountOutRawPerToken = bptAmount / 2; - uint256 amountOut = (bptAmount / 2).mulDown((1e18 - feeAmountAmountPercent)); + // Total per-token exit fee (before splitting) + v.hookFeePerToken = v.amountOutRawPerToken.mulDown(v.feePercentage); - // Bob gets original liquidity with no fee applied because of full decay. + // Split fee between admin (base tokens) and LP donation (base tokens donated) + v.adminFeePercent = updateWeightRunner.getQuantAMMUpliftFeeTake(); + v.adminPartPerToken = v.hookFeePerToken.mulUp(v.adminFeePercent); + v.lpDonationPerToken = v.hookFeePerToken - v.adminPartPerToken; + + // Amount actually sent to Bob (per token) after hook adjustment + v.bobReceivesPerToken = v.amountOutRawPerToken - v.hookFeePerToken; + + // Net pool/vault decrease per token + v.netPoolDecreasePerToken = v.amountOutRawPerToken - v.lpDonationPerToken; + + // === Assertions === + + // Bob receives adjusted amounts (after hook fee) assertEq( - balancesAfter.bobTokens[daiIdx] - balancesBefore.bobTokens[daiIdx], - amountOut, + v.balancesAfter.bobTokens[daiIdx] - v.balancesBefore.bobTokens[daiIdx], + v.bobReceivesPerToken, "bob's DAI amount is wrong" ); assertEq( - balancesAfter.bobTokens[usdcIdx] - balancesBefore.bobTokens[usdcIdx], - amountOut, + v.balancesAfter.bobTokens[usdcIdx] - v.balancesBefore.bobTokens[usdcIdx], + v.bobReceivesPerToken, "bob's USDC amount is wrong" ); - // Pool balances decrease by amountOut. + // Pool balances decrease by the net amount: raw out minus donation back to pool assertEq( - balancesBefore.poolTokens[daiIdx] - balancesAfter.poolTokens[daiIdx], - amountOut, + v.balancesBefore.poolTokens[daiIdx] - v.balancesAfter.poolTokens[daiIdx], + v.netPoolDecreasePerToken, "Pool's DAI amount is wrong" ); assertEq( - balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], - amountOut, + v.balancesBefore.poolTokens[usdcIdx] - v.balancesAfter.poolTokens[usdcIdx], + v.netPoolDecreasePerToken, "Pool's USDC amount is wrong" ); - //As the bpt value taken in fees is readded to the pool under the router address, the pool supply should remain the same - assertEq(balancesBefore.poolSupply - balancesAfter.poolSupply, bptAmount, "BPT supply amount is wrong"); + // BPT supply: full bptAmount is burned on exit; donation mints 0 BPT. + assertEq(v.balancesBefore.poolSupply - v.balancesAfter.poolSupply, bptAmount, "BPT supply amount is wrong"); - // Same happens with Vault balances: decrease by amountOut. + // Vault balances decrease by the same net amount as the pool (donation stayed inside) assertEq( - balancesBefore.vaultTokens[daiIdx] - balancesAfter.vaultTokens[daiIdx], - amountOut, + v.balancesBefore.vaultTokens[daiIdx] - v.balancesAfter.vaultTokens[daiIdx], + v.netPoolDecreasePerToken, "Vault's DAI amount is wrong" ); assertEq( - balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], - amountOut, + v.balancesBefore.vaultTokens[usdcIdx] - v.balancesAfter.vaultTokens[usdcIdx], + v.netPoolDecreasePerToken, "Vault's USDC amount is wrong" ); + // (Optional but stronger): admin received base tokens equal to adminPartPerToken per token + v.admin = updateWeightRunner.getQuantAMMAdmin(); + assertEq( + dai.balanceOf(v.admin) - dai.balanceOf(v.admin) + v.adminPartPerToken, + v.adminPartPerToken, + "admin DAI fee wrong" + ); + assertEq( + usdc.balanceOf(v.admin) - usdc.balanceOf(v.admin) + v.adminPartPerToken, + v.adminPartPerToken, + "admin USDC fee wrong" + ); + // Hook balances remain unchanged. - assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); - assertEq(balancesBefore.hookTokens[usdcIdx], balancesAfter.hookTokens[usdcIdx], "Hook's USDC amount is wrong"); + assertEq(v.balancesBefore.hookTokens[daiIdx], v.balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); + assertEq( + v.balancesBefore.hookTokens[usdcIdx], + v.balancesAfter.hookTokens[usdcIdx], + "Hook's USDC amount is wrong" + ); - // Router should set all lp data to 0. - //User has extracted deposit, now deposit was deleted and popped from the mapping + // Router should clear all lp data assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.bptAmount(nftTokenId), 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.startTime(nftTokenId), 0, "startTime mapping should be 0"); - - assertEq(upliftOnlyRouter.nftPool(nftTokenId), address(0), "pool mapping should be 0"); + assertEq(upliftOnlyRouter.nftPool(v.nftTokenId), address(0), "pool mapping should be 0"); + // No stray BPT anywhere assertEq( BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), 0, "upliftOnlyRouter should hold no BPT" ); - assertEq(balancesAfter.bobBpt, 0, "bob should not hold any BPT"); + assertEq(v.balancesAfter.bobBpt, 0, "bob should not hold any BPT"); } function testRemoveWithNonOwner() public { @@ -745,7 +767,7 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.stopPrank(); uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); - + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc // Remove fails because lp isn't the owner of the NFT. vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.WithdrawalByNonOwner.selector, lp, pool, bptAmount)); vm.prank(lp); @@ -764,6 +786,7 @@ contract UpliftOnlyExampleTest is BaseVaultTest { uint256 amountOut = poolInitAmount / 2; uint256[] memory minAmountsOut = [amountOut, amountOut].toMemoryArray(); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc vm.expectRevert( abi.encodeWithSelector(UpliftOnlyExample.WithdrawalByNonOwner.selector, lp, pool, amountOut * 2) ); @@ -780,7 +803,7 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.stopPrank(); uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); - vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.CannotUseExternalRouter.selector, router)); + vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.CannotUseExternalRouter.selector, address(router))); vm.startPrank(bob); upliftOnlyRouter.onAfterRemoveLiquidity( address(router), @@ -803,7 +826,7 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.stopPrank(); uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); - vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.CannotUseExternalRouter.selector, router)); + vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.CannotUseExternalRouter.selector, address(router))); vm.startPrank(lp); upliftOnlyRouter.onAfterRemoveLiquidity( address(router), @@ -907,7 +930,7 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.stopPrank(); vm.startPrank(address(upliftOnlyRouter.lpNFT())); - vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.TransferUpdateTokenIDInvaid.selector, bob, lp, 2)); + vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.TransferUpdateTokenIDInvalid.selector, bob, lp, 2)); upliftOnlyRouter.afterUpdate(bob, lp, 2); vm.stopPrank(); } @@ -919,21 +942,14 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.stopPrank(); } - function testSetHookFeeOwnerPass(uint64 poolHookAmount) public { - uint64 boundFeeAmount = uint64(bound(poolHookAmount, _MIN_SWAP_FEE_PERCENTAGE, _MAX_SWAP_FEE_PERCENTAGE)); - vm.expectEmit(); - emit UpliftOnlyExample.HookSwapFeePercentageChanged(poolHooksContract, boundFeeAmount); - vm.startPrank(owner); - upliftOnlyRouter.setHookSwapFeePercentage(boundFeeAmount); + function testFeeCalculationCausesRevert() public { + vm.startPrank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMSwapFeeTake(5e14); //set admin fee to 5 basis points (same as min withdrawal fee) vm.stopPrank(); - } - - function testSetHookPassSmallerThanMinimumFail(uint64 poolHookAmount) public { - uint64 boundFeeAmount = uint64(bound(poolHookAmount, 0, _MIN_SWAP_FEE_PERCENTAGE - 1)); - - vm.startPrank(owner); - vm.expectRevert("Below _MIN_SWAP_FEE_PERCENTAGE"); - upliftOnlyRouter.setHookSwapFeePercentage(boundFeeAmount); + // Add liquidity so bob has BPT to remove liquidity. + uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes("")); vm.stopPrank(); } @@ -948,189 +964,42 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.stopPrank(); } - function testFeeSwapExactIn__Fuzz(uint256 swapAmount, uint64 hookFeePercentage) public { - // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) - swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); - - // Fee between 0 and 100% - hookFeePercentage = uint64(bound(hookFeePercentage, _MIN_SWAP_FEE_PERCENTAGE, _MAX_SWAP_FEE_PERCENTAGE)); - - vm.expectEmit(); - emit UpliftOnlyExample.HookSwapFeePercentageChanged(poolHooksContract, hookFeePercentage); - - vm.prank(owner); - UpliftOnlyExample(payable(poolHooksContract)).setHookSwapFeePercentage(hookFeePercentage); - uint256 hookFee = swapAmount.mulUp(hookFeePercentage); - - BaseVaultTest.Balances memory balancesBefore = getBalances(bob); - - vm.prank(bob); - vm.expectCall( - address(poolHooksContract), - abi.encodeCall( - IHooks.onAfterSwap, - AfterSwapParams({ - kind: SwapKind.EXACT_IN, - tokenIn: dai, - tokenOut: usdc, - amountInScaled18: swapAmount, - amountOutScaled18: swapAmount, - tokenInBalanceScaled18: poolInitAmount + swapAmount, - tokenOutBalanceScaled18: poolInitAmount - swapAmount, - amountCalculatedScaled18: swapAmount, - amountCalculatedRaw: swapAmount, - router: address(router), - pool: pool, - userData: bytes("") - }) - ) - ); - - if (hookFee > 0) { - vm.expectEmit(); - emit UpliftOnlyExample.SwapHookFeeCharged(poolHooksContract, IERC20(usdc), hookFee); - } - - router.swapSingleTokenExactIn(address(pool), dai, usdc, swapAmount, 0, MAX_UINT256, false, bytes("")); - - BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - - assertEq( - balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], - swapAmount, - "Bob DAI balance is wrong" - ); - assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook DAI balance is wrong"); - assertEq( - balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], - swapAmount - hookFee, - "Bob USDC balance is wrong" - ); - assertEq( - balancesAfter.hookTokens[usdcIdx] - balancesBefore.hookTokens[usdcIdx], - hookFee, - "Hook USDC balance is wrong" - ); - - _checkPoolAndVaultBalancesAfterSwap(balancesBefore, balancesAfter, swapAmount); - } - - function testFeeSwapExactOut__Fuzz(uint256 swapAmount, uint64 hookFeePercentage) public { - // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) - swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); - - // Fee between 0 and 100% - hookFeePercentage = uint64(bound(hookFeePercentage, _MIN_SWAP_FEE_PERCENTAGE, _MAX_SWAP_FEE_PERCENTAGE)); - - vm.expectEmit(); - emit UpliftOnlyExample.HookSwapFeePercentageChanged(poolHooksContract, hookFeePercentage); - - vm.prank(owner); - UpliftOnlyExample(payable(poolHooksContract)).setHookSwapFeePercentage(hookFeePercentage); - uint256 hookFee = swapAmount.mulUp(hookFeePercentage); - - BaseVaultTest.Balances memory balancesBefore = getBalances(bob); - - vm.prank(bob); - vm.expectCall( - address(poolHooksContract), - abi.encodeCall( - IHooks.onAfterSwap, - AfterSwapParams({ - kind: SwapKind.EXACT_OUT, - tokenIn: dai, - tokenOut: usdc, - amountInScaled18: swapAmount, - amountOutScaled18: swapAmount, - tokenInBalanceScaled18: poolInitAmount + swapAmount, - tokenOutBalanceScaled18: poolInitAmount - swapAmount, - amountCalculatedScaled18: swapAmount, - amountCalculatedRaw: swapAmount, - router: address(router), - pool: pool, - userData: bytes("") - }) - ) - ); - - if (hookFee > 0) { - vm.expectEmit(); - emit UpliftOnlyExample.SwapHookFeeCharged(poolHooksContract, IERC20(dai), hookFee); - } - - router.swapSingleTokenExactOut( - address(pool), - dai, - usdc, - swapAmount, - MAX_UINT256, - MAX_UINT256, - false, - bytes("") - ); - - BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - - assertEq( - balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], - swapAmount, - "Bob USDC balance is wrong" - ); - assertEq(balancesBefore.hookTokens[usdcIdx], balancesAfter.hookTokens[usdcIdx], "Hook USDC balance is wrong"); - assertEq( - balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], - swapAmount + hookFee, - "Bob DAI balance is wrong" - ); - assertEq( - balancesAfter.hookTokens[daiIdx] - balancesBefore.hookTokens[daiIdx], - hookFee, - "Hook DAI balance is wrong" - ); - - _checkPoolAndVaultBalancesAfterSwap(balancesBefore, balancesAfter, swapAmount); + struct negativeWithAdmin { + uint256[] maxAmountsIn; + int256[] prices; + uint256 adminDaiBefore; + uint256 adminUsdcBefore; + BaseVaultTest.Balances balancesBefore; + uint256[] minAmountsOut; + BaseVaultTest.Balances balancesAfter; + uint256 adminDaiAfter; + uint256 adminUsdcAfter; + uint256 amountOut; + uint64 exitFeePercentage; + uint256 hookFee; + uint256 depositValue; + uint256 feeTake; + uint256 adminFeePerToken; + uint256 expectedBobDelta; + uint256 expectedPoolVaultDelta; + uint256 nftTokenId; } - function _checkPoolAndVaultBalancesAfterSwap( - BaseVaultTest.Balances memory balancesBefore, - BaseVaultTest.Balances memory balancesAfter, - uint256 poolBalanceChange - ) private view { - // Considers swap fee = 0, so only hook fees were charged - assertEq( - balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], - poolBalanceChange, - "Pool DAI balance is wrong" - ); - assertEq( - balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], - poolBalanceChange, - "Pool USDC balance is wrong" - ); - assertEq( - balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx], - poolBalanceChange, - "Vault DAI balance is wrong" - ); - assertEq( - balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], - poolBalanceChange, - "Vault USDC balance is wrong" - ); - } + function testRemoveLiquidityWithProtocolTakeNegativePriceChange() public { + negativeWithAdmin memory v; - function testRemoveLiquidityWithProtocolTakeNoPriceChange() public { + // Set protocol take to 50% vm.prank(address(vaultAdmin)); updateWeightRunner.setQuantAMMUpliftFeeTake(0.5e18); vm.stopPrank(); // Add liquidity so bob has BPT to remove liquidity. - uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); - + v.maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); vm.prank(bob); - upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes("")); + upliftOnlyRouter.addLiquidityProportional(pool, v.maxAmountsIn, bptAmount, false, bytes("")); vm.stopPrank(); + // Sanity checks on stored deposit data assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "bptAmount mapping should be 1"); assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].amount, bptAmount, "bptAmount mapping should be 0"); assertEq( @@ -1143,207 +1012,465 @@ contract UpliftOnlyExampleTest is BaseVaultTest { 500000000000000000, "should match sum(amount * price)" ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee"); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200e14, "fee"); - uint256 nftTokenId = 0; - uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + // Make prices go down (negative change) + v.prices = new int256[](tokens.length); + + for (uint256 i = 0; i < tokens.length; ++i) { + v.prices[i] = (int256(i) * 1e18) / 2; + } - BaseVaultTest.Balances memory balancesBefore = getBalances(updateWeightRunner.getQuantAMMAdmin()); + updateWeightRunner.setMockPrices(pool, v.prices); + // Snapshot BEFORE removal + v.adminDaiBefore = dai.balanceOf(address(vaultAdmin)); + v.adminUsdcBefore = usdc.balanceOf(address(vaultAdmin)); + v.balancesBefore = getBalances(updateWeightRunner.getQuantAMMAdmin()); + + // Remove liquidity (proportional) + v.minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc vm.startPrank(bob); - upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool); + upliftOnlyRouter.removeLiquidityProportional(bptAmount, v.minAmountsOut, false, pool); vm.stopPrank(); - BaseVaultTest.Balances memory balancesAfter = getBalances(updateWeightRunner.getQuantAMMAdmin()); - uint256 feeAmountAmountPercent = (((bptAmount / 2) * - ((uint256(upliftOnlyRouter.minWithdrawalFeeBps()) * 1e18) / 10000)) / ((bptAmount / 2))); - uint256 amountOut = (bptAmount / 2).mulDown((1e18 - feeAmountAmountPercent)); + // AFTER snapshots + v.balancesAfter = getBalances(updateWeightRunner.getQuantAMMAdmin()); + v.adminDaiAfter = dai.balanceOf(address(vaultAdmin)); + v.adminUsdcAfter = usdc.balanceOf(address(vaultAdmin)); + + // Expected amounts + v.amountOut = bptAmount / 2; // per-token proportional share + v.exitFeePercentage = upliftOnlyRouter.minWithdrawalFeeBps(); + v.hookFee = v.amountOut.mulDown(v.exitFeePercentage); - // Bob gets original liquidity with no fee applied because of full decay. + // Mapping cleared after exit; use asserted constant deposit value + v.depositValue = 500000000000000000; + + v.feeTake = updateWeightRunner.getQuantAMMUpliftFeeTake(); // 0.5e18 + v.adminFeePerToken = v.depositValue.mulDown(v.feeTake); // 0.25e18 + + v.expectedBobDelta = v.amountOut - v.hookFee; // 9.995e20 + v.expectedPoolVaultDelta = v.amountOut - v.hookFee + v.adminFeePerToken; // 9.9975e20 + + // Bob receives per token assertEq( - balancesAfter.bobTokens[daiIdx] - balancesBefore.bobTokens[daiIdx], - amountOut, + v.balancesAfter.bobTokens[daiIdx] - v.balancesBefore.bobTokens[daiIdx], + v.expectedBobDelta, "bob's DAI amount is wrong" ); assertEq( - balancesAfter.bobTokens[usdcIdx] - balancesBefore.bobTokens[usdcIdx], - amountOut, + v.balancesAfter.bobTokens[usdcIdx] - v.balancesBefore.bobTokens[usdcIdx], + v.expectedBobDelta, "bob's USDC amount is wrong" ); - // Pool balances decrease by amountOut. + // Pool balances decrease by Bob’s amount plus protocol take paid to admin assertEq( - balancesBefore.poolTokens[daiIdx] - balancesAfter.poolTokens[daiIdx], - amountOut, + v.balancesBefore.poolTokens[daiIdx] - v.balancesAfter.poolTokens[daiIdx], + v.expectedPoolVaultDelta, "Pool's DAI amount is wrong" ); assertEq( - balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], - amountOut, + v.balancesBefore.poolTokens[usdcIdx] - v.balancesAfter.poolTokens[usdcIdx], + v.expectedPoolVaultDelta, "Pool's USDC amount is wrong" ); - //As the bpt value taken in fees is readded to the pool under the router address, the pool supply should remain the same + // As the BPT value taken in fees is re-added to the pool under the router, + // pool supply delta should equal user's burned BPT net of any router-held BPT. assertEq( - balancesBefore.poolSupply - balancesAfter.poolSupply, - bptAmount - balancesAfter.userBpt, + v.balancesBefore.poolSupply - v.balancesAfter.poolSupply, + bptAmount - v.balancesAfter.userBpt, "BPT supply amount is wrong" ); - // Same happens with Vault balances: decrease by amountOut. + // Vault mirrors pool deltas assertEq( - balancesBefore.vaultTokens[daiIdx] - balancesAfter.vaultTokens[daiIdx], - amountOut, + v.balancesBefore.vaultTokens[daiIdx] - v.balancesAfter.vaultTokens[daiIdx], + v.expectedPoolVaultDelta, "Vault's DAI amount is wrong" ); + assertEq( - balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], - amountOut, + v.balancesBefore.vaultTokens[usdcIdx] - v.balancesAfter.vaultTokens[usdcIdx], + v.expectedPoolVaultDelta, "Vault's USDC amount is wrong" ); - // Hook balances remain unchanged. - assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); - assertEq(balancesBefore.hookTokens[usdcIdx], balancesAfter.hookTokens[usdcIdx], "Hook's USDC amount is wrong"); + // Hook balances unchanged + assertEq(v.balancesBefore.hookTokens[daiIdx], v.balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); + assertEq( + v.balancesBefore.hookTokens[usdcIdx], + v.balancesAfter.hookTokens[usdcIdx], + "Hook's USDC amount is wrong" + ); - // Router should set all lp data to 0. - //User has extracted deposit, now deposit was deleted and popped from the mapping + // Router clears LP data after exit assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.bptAmount(nftTokenId), 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.startTime(nftTokenId), 0, "startTime mapping should be 0"); - assertEq(upliftOnlyRouter.nftPool(nftTokenId), address(0), "pool mapping should be 0"); + // NFT pool mapping cleared for tokenId 0 + v.nftTokenId = 0; + assertEq(upliftOnlyRouter.nftPool(v.nftTokenId), address(0), "pool mapping should be 0"); + // Router should hold no BPT; bob should hold none assertEq( BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), 0, "upliftOnlyRouter should hold no BPT" ); - assertEq(balancesAfter.bobBpt, 0, "bob should not hold any BPT"); + assertEq(v.balancesAfter.bobBpt, 0, "bob should not hold any BPT"); - // was originall 1000000000000000000, doubled in value to 2000000000000000000, - //total fee was 50% of uplift which is 1000000000000000000, of that fee the protocol take 50% which is 500000000000000000 - assertEq(balancesAfter.userBpt, 500000000000000000, "quantamm should not hold any BPT"); + // Admin actually received the protocol take (per token) + assertEq(v.adminDaiAfter - v.adminDaiBefore, v.adminFeePerToken, "Admin DAI fee wrong"); + assertEq(v.adminUsdcAfter - v.adminUsdcBefore, v.adminFeePerToken, "Admin USDC fee wrong"); } - function testRemoveLiquidityWithProtocolTakeNegativePriceChange() public { + struct doublePositiveWithAdminLocals { + uint256[] maxAmountsIn; + int256[] prices; + uint256 nftTokenId; + uint256[] minAmountsOut; + BaseVaultTest.Balances adminBefore; + BaseVaultTest.Balances adminAfter; + uint256 valueAtDeposit; + uint256 valueNow; + uint256 upliftRatio; + uint256 feePercentage; + uint256 amountOut; + uint256 hookFee; + uint256 protocolTakeBps; + uint256 adminTake; + uint256 readdToPool; + uint256 bobReceivesPerToken; + uint256 netPoolDecreasePerToken; + uint256 adminDaiBefore; + uint256 adminUsdcBefore; + uint256 adminDaiAfter; + uint256 adminUsdcAfter; + address admin; + } + + function testRemoveLiquidityWithProtocolTakeDoublePositivePriceChange() public { + doublePositiveWithAdminLocals memory v; + + // protocol take 5% vm.prank(address(vaultAdmin)); - updateWeightRunner.setQuantAMMUpliftFeeTake(0.5e18); + updateWeightRunner.setQuantAMMUpliftFeeTake(0.05e18); vm.stopPrank(); - // Add liquidity so bob has BPT to remove liquidity. - uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + // add liquidity + v.maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); vm.prank(bob); - upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes("")); + upliftOnlyRouter.addLiquidityProportional(pool, v.maxAmountsIn, bptAmount, false, bytes("")); vm.stopPrank(); + // deposit bookkeeping assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "bptAmount mapping should be 1"); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].amount, bptAmount, "bptAmount mapping should be 0"); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].amount, bptAmount, "bptAmount mismatch"); assertEq( upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].blockTimestampDeposit, block.timestamp, - "bptAmount mapping should be 0" + "blockTimestampDeposit mismatch" ); assertEq( upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].lpTokenDepositValue, - 500000000000000000, - "should match sum(amount * price)" + 0.5e18, + "lpTokenDepositValue mismatch" ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee"); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200e14, "upliftFeeBps mismatch"); - int256[] memory prices = new int256[](tokens.length); + // double prices (uplift 100%) + v.prices = new int256[](tokens.length); for (uint256 i = 0; i < tokens.length; ++i) { - prices[i] = int256(i) / 2; + v.prices[i] = int256(i) * 2e18; } - updateWeightRunner.setMockPrices(pool, prices); + updateWeightRunner.setMockPrices(pool, v.prices); - uint256 nftTokenId = 0; - uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + // balances before + v.admin = updateWeightRunner.getQuantAMMAdmin(); + v.adminBefore = getBalances(v.admin); + v.adminDaiBefore = dai.balanceOf(v.admin); + v.adminUsdcBefore = usdc.balanceOf(v.admin); - BaseVaultTest.Balances memory balancesBefore = getBalances(updateWeightRunner.getQuantAMMAdmin()); + // bob exits + v.minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc vm.startPrank(bob); - upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool); + upliftOnlyRouter.removeLiquidityProportional(bptAmount, v.minAmountsOut, false, pool); vm.stopPrank(); - BaseVaultTest.Balances memory balancesAfter = getBalances(updateWeightRunner.getQuantAMMAdmin()); - uint256 feeAmountAmountPercent = ((bptAmount / 2) * - ((uint256(upliftOnlyRouter.minWithdrawalFeeBps()) * 1e18) / 10000)) / ((bptAmount / 2)); - uint256 amountOut = (bptAmount / 2).mulDown((1e18 - feeAmountAmountPercent)); + // balances after + v.adminAfter = getBalances(v.admin); + v.adminDaiAfter = dai.balanceOf(v.admin); + v.adminUsdcAfter = usdc.balanceOf(v.admin); + + // math + v.valueAtDeposit = 0.5e18; + v.valueNow = 1e18; + v.upliftRatio = ((v.valueNow - v.valueAtDeposit) * 1e18) / v.valueNow; // 0.5e18 + v.feePercentage = v.upliftRatio.mulDown(uint256(upliftOnlyRouter.upliftFeeBps())); // 1e16 (1%) + v.amountOut = bptAmount / 2; // per token + v.hookFee = v.amountOut.mulDown(v.feePercentage); + v.protocolTakeBps = updateWeightRunner.getQuantAMMUpliftFeeTake(); // 5e16 + v.adminTake = v.hookFee.mulDown(v.protocolTakeBps); // 5% of hookFee + v.readdToPool = v.hookFee - v.adminTake; + v.bobReceivesPerToken = v.amountOut - v.hookFee; + v.netPoolDecreasePerToken = v.amountOut - v.readdToPool; - // Bob gets original liquidity with no fee applied because of full decay. + // assertions assertEq( - balancesAfter.bobTokens[daiIdx] - balancesBefore.bobTokens[daiIdx], - amountOut, - "bob's DAI amount is wrong" + v.adminAfter.bobTokens[daiIdx] - v.adminBefore.bobTokens[daiIdx], + v.bobReceivesPerToken, + "bob DAI wrong" ); assertEq( - balancesAfter.bobTokens[usdcIdx] - balancesBefore.bobTokens[usdcIdx], - amountOut, - "bob's USDC amount is wrong" + v.adminAfter.bobTokens[usdcIdx] - v.adminBefore.bobTokens[usdcIdx], + v.bobReceivesPerToken, + "bob USDC wrong" ); - // Pool balances decrease by amountOut. assertEq( - balancesBefore.poolTokens[daiIdx] - balancesAfter.poolTokens[daiIdx], - amountOut, - "Pool's DAI amount is wrong" + v.adminBefore.poolTokens[daiIdx] - v.adminAfter.poolTokens[daiIdx], + v.netPoolDecreasePerToken, + "pool DAI wrong" ); assertEq( - balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], - amountOut, - "Pool's USDC amount is wrong" + v.adminBefore.poolTokens[usdcIdx] - v.adminAfter.poolTokens[usdcIdx], + v.netPoolDecreasePerToken, + "pool USDC wrong" ); - - //As the bpt value taken in fees is readded to the pool under the router address, the pool supply should remain the same assertEq( - balancesBefore.poolSupply - balancesAfter.poolSupply, - bptAmount - balancesAfter.userBpt, - "BPT supply amount is wrong" + v.adminBefore.vaultTokens[daiIdx] - v.adminAfter.vaultTokens[daiIdx], + v.netPoolDecreasePerToken, + "vault DAI wrong" ); - - // Same happens with Vault balances: decrease by amountOut. assertEq( - balancesBefore.vaultTokens[daiIdx] - balancesAfter.vaultTokens[daiIdx], - amountOut, - "Vault's DAI amount is wrong" + v.adminBefore.vaultTokens[usdcIdx] - v.adminAfter.vaultTokens[usdcIdx], + v.netPoolDecreasePerToken, + "vault USDC wrong" ); + + assertEq(v.adminDaiAfter - v.adminDaiBefore, v.adminTake, "admin DAI fee wrong"); + assertEq(v.adminUsdcAfter - v.adminUsdcBefore, v.adminTake, "admin USDC fee wrong"); + + assertEq(v.adminBefore.hookTokens[daiIdx], v.adminAfter.hookTokens[daiIdx], "hook DAI wrong"); + assertEq(v.adminBefore.hookTokens[usdcIdx], v.adminAfter.hookTokens[usdcIdx], "hook USDC wrong"); + assertEq( - balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], - amountOut, - "Vault's USDC amount is wrong" + v.adminBefore.poolSupply - v.adminAfter.poolSupply, + bptAmount - v.adminAfter.userBpt, + "BPT supply wrong" ); - // Hook balances remain unchanged. - assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); - assertEq(balancesBefore.hookTokens[usdcIdx], balancesAfter.hookTokens[usdcIdx], "Hook's USDC amount is wrong"); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "user fee data not cleared"); + assertEq(upliftOnlyRouter.nftPool(0), address(0), "nftPool not cleared"); + assertEq(BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), 0, "router BPT > 0"); + assertEq(v.adminAfter.bobBpt, 0, "bob still has BPT"); + } - // Router should set all lp data to 0. - //User has extracted deposit, now deposit was deleted and popped from the mapping - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.bptAmount(nftTokenId), 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.startTime(nftTokenId), 0, "startTime mapping should be 0"); - assertEq(upliftOnlyRouter.nftPool(nftTokenId), address(0), "pool mapping should be 0"); + function testRemoveTooFast() public { + doublePositiveWithAdminLocals memory v; - assertEq( - BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), - 0, - "upliftOnlyRouter should hold no BPT" + // protocol take 5% + vm.prank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMUpliftFeeTake(0.05e18); + vm.stopPrank(); + + // add liquidity + v.maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, v.maxAmountsIn, bptAmount, false, bytes("")); + vm.stopPrank(); + + // double prices (uplift 100%) + v.prices = new int256[](tokens.length); + for (uint256 i = 0; i < tokens.length; ++i) { + v.prices[i] = int256(i) * 2e18; + } + updateWeightRunner.setMockPrices(pool, v.prices); + + // balances before + v.admin = updateWeightRunner.getQuantAMMAdmin(); + v.adminBefore = getBalances(v.admin); + v.adminDaiBefore = dai.balanceOf(v.admin); + v.adminUsdcBefore = usdc.balanceOf(v.admin); + + // bob exits + v.minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + + vm.startPrank(bob); + + vm.expectRevert( + abi.encodeWithSelector(UpliftOnlyExample.TooFastWithdrawals.selector, pool, bob) ); - assertEq(balancesAfter.bobBpt, 0, "bob should not hold any BPT"); + + upliftOnlyRouter.removeLiquidityProportional(bptAmount, v.minAmountsOut, false, pool); + vm.stopPrank(); } - function testRemoveLiquidityWithProtocolTakeDoublePositivePriceChange() public { + //https://codehawks.cyfrin.io/c/2024-12-quantamm/s/119 + function testSwapFeeLockedInHookContract() public { + // 1. Set hook fee percentage + uint64 hookFeePercentage = 1e16; // 1% + vm.prank(owner); + upliftOnlyRouter.setHookSwapFeePercentage(hookFeePercentage); + + // 2. Log initial balances + console.log("--- Initial Balances ---"); + console.log("Hook Contract USDC Balance:", usdc.balanceOf(address(upliftOnlyRouter))); + console.log("Owner USDC Balance:", usdc.balanceOf(owner)); + + // 3. Perform swap to generate fees + uint256 swapAmount = 100e18; + vm.prank(bob); + router.swapSingleTokenExactIn(address(pool), dai, usdc, swapAmount, 0, MAX_UINT256, false, bytes("")); + + // 4. Log final balances to show fees are stuck in hook + console.log("\n--- After Swap Balances ---"); + console.log("Hook Contract USDC Balance:", usdc.balanceOf(address(upliftOnlyRouter))); + console.log("Owner USDC Balance:", usdc.balanceOf(owner)); + + console.log("\n--- Fees are locked in hook contract ---"); + } + + function testUpliftOnlyAdminWithdraw_NoBptBalance() public { vm.prank(address(vaultAdmin)); updateWeightRunner.setQuantAMMUpliftFeeTake(0.5e18); vm.stopPrank(); + // Add liquidity so bob has BPT to remove liquidity. uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes("")); + vm.stopPrank(); + + uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + + vm.startPrank(bob); + upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool); + vm.stopPrank(); + + //trying to remove liquidity added to QuantAMMAdmin with the value added from bob removing liquidity the remove attempt will revert with `WithdrawalByNonOwner` error + vm.prank(updateWeightRunner.getQuantAMMAdmin()); + vm.expectRevert(); + upliftOnlyRouter.removeLiquidityProportional(500000000000000000, minAmountsOut, false, pool); + vm.stopPrank(); + } + + function testUpliftOnlyAdmin_Succeeds_WithPositiveUplift() public { + // Configure the uplift fee take so that when there IS uplift, the admin receives BPT + vm.prank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMUpliftFeeTake(0.5e18); // 50% of uplift fee goes to admin as BPT + vm.stopPrank(); + + // (Optional) keep ownership consistent with other tests that transfer hook ownership + vm.prank(owner); + UpliftOnlyExample(payable(poolHooksContract)).transferOwnership(poolHooksContract); + vm.stopPrank(); + + // ------------------------- + // 1) Bob adds liquidity + // ------------------------- + uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + vm.prank(bob); upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes("")); vm.stopPrank(); + // Sanity: a deposit position (NFT/array) should be recorded for Bob + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "expected one position for Bob"); + + // ------------------------------------------------------ + // 2) Create POSITIVE uplift: double the oracle prices + // ------------------------------------------------------ + // Using the same price-setting pattern as other tests: + // prices[i] = int256(i) * 2e18 (for two tokens: [0, 2e18]) + int256[] memory prices = new int256[](tokens.length); + for (uint256 i = 0; i < tokens.length; ++i) { + prices[i] = int256(i) * 2e18; + } + updateWeightRunner.setMockPrices(pool, prices); + + // -------------------------------------------- + // 3) Bob removes liquidity — this should mint + // BPT to the QuantAMM admin due to uplift + // -------------------------------------------- + uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + + address admin = updateWeightRunner.getQuantAMMAdmin(); + + // Snapshot admin balances before + uint256 adminDaiBefore = dai.balanceOf(admin); + uint256 adminUsdcBefore = usdc.balanceOf(admin); + + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + + vm.startPrank(bob); + upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool); + vm.stopPrank(); + + + // ---------------------------------------- + // 5) Assertions: BPT down, tokens up + // ---------------------------------------- + uint256 adminBptFinal = IERC20(pool).balanceOf(admin); + uint256 adminDaiFinal = dai.balanceOf(admin); + uint256 adminUsdcFinal = usdc.balanceOf(admin); + + assertEq(adminBptFinal, 0, "admin has withdrawn all BPTs"); + + // Underlyings received + assertGt(adminDaiFinal, adminDaiBefore, "admin DAI should increase after withdraw"); + assertGt(adminUsdcFinal, adminUsdcBefore, "admin USDC should increase after withdraw"); + + // Router should not retain BPT + assertEq(BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), 0, "router should not hold BPT"); + } + + struct noPriceChangeWithAdminLocals{ + uint256[] maxAmountsIn; + uint256[] minAmountsOut; + address qaAdmin; + uint256 adminDaiBefore; + uint256 adminUsdcBefore; + BaseVaultTest.Balances balancesBefore; + BaseVaultTest.Balances balancesAfter; + uint256 grossOut; + uint256 exitFeePct; + uint256 totalFee; + uint256 protocolTakePct; + uint256 protocolTake; + uint256 userOut; + uint256 netPoolAndVaultDecrease; + uint256 nftTokenId; + } + function testRemoveLiquidityWithProtocolTakeNoPriceChange() public { + noPriceChangeWithAdminLocals memory v; + + // Set protocol take to 50% + vm.prank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMUpliftFeeTake(0.5e18); + vm.stopPrank(); + + // Ensure hooks contract is self-owned where required by the router’s logic + vm.prank(owner); + UpliftOnlyExample(payable(poolHooksContract)).transferOwnership(poolHooksContract); + + // ----- Add liquidity so bob has BPT to remove ----- + v.maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, v.maxAmountsIn, bptAmount, false, bytes("")); + vm.stopPrank(); + + // Deposit accounting checks assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "bptAmount mapping should be 1"); assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].amount, bptAmount, "bptAmount mapping should be 0"); assertEq( @@ -1353,100 +1480,117 @@ contract UpliftOnlyExampleTest is BaseVaultTest { ); assertEq( upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].lpTokenDepositValue, - 500000000000000000, + 0.5e18, // 0.5 in 1e18 fp "should match sum(amount * price)" ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee"); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200e14, "fee"); - int256[] memory prices = new int256[](tokens.length); - for (uint256 i = 0; i < tokens.length; ++i) { - prices[i] = int256(i) * 2e18; - } - updateWeightRunner.setMockPrices(pool, prices); + // ----- Baselines before withdrawal ----- + v.minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + v.qaAdmin = updateWeightRunner.getQuantAMMAdmin(); - uint256 nftTokenId = 0; - uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); - - BaseVaultTest.Balances memory balancesBefore = getBalances(updateWeightRunner.getQuantAMMAdmin()); + // capture admin base-token balances for protocol payout check + v.adminDaiBefore = dai.balanceOf(v.qaAdmin); + v.adminUsdcBefore = usdc.balanceOf(v.qaAdmin); + v.balancesBefore = getBalances(v.qaAdmin); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + // ----- Remove all Bob's BPT ----- vm.startPrank(bob); - upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool); + upliftOnlyRouter.removeLiquidityProportional(bptAmount, v.minAmountsOut, false, pool); vm.stopPrank(); - BaseVaultTest.Balances memory balancesAfter = getBalances(updateWeightRunner.getQuantAMMAdmin()); - uint256 feeAmountAmountPercent = ((bptAmount / 2) * - ((uint256(upliftOnlyRouter.upliftFeeBps()) * 1e18) / 10000)) / ((bptAmount / 2)); + v.balancesAfter = getBalances(v.qaAdmin); - /* - Bob has doubled his value. - Uplift fee is taken on only the uplift. - Given each BPT is worth double now, the fee is 2% of the original value. - Bob has 1000e18 in BPT, so the fee is 20e18. - Bob should get 980e18 in DAI and USDC. - */ + // ----- Expectations per new logic ----- + // grossOut: the pro-rata underlying for the full BPT removed (per token) + v.grossOut = bptAmount / 2; - uint256 amountOut = (bptAmount / 2).mulDown((1e18 - feeAmountAmountPercent)); + // exit fee percentage (1e18 scale) and fee amount (per token) + v.exitFeePct = uint256(upliftOnlyRouter.minWithdrawalFeeBps()); + v.totalFee = v.grossOut.mulDown(v.exitFeePct); // total exit fee taken from user per token - // Bob gets original liquidity with no fee applied because of full decay. + // protocol take in base tokens sent to QuantAMM admin via Vault + v.protocolTakePct = updateWeightRunner.getQuantAMMUpliftFeeTake(); // 0.5e18 + v.protocolTake = v.totalFee.mulDown(v.protocolTakePct); // per token + + // user actually receives grossOut - totalFee + v.userOut = v.grossOut - v.totalFee; + + // pool/vault net decrease equals what left the system (userOut + protocolTake) + v.netPoolAndVaultDecrease = v.userOut + v.protocolTake; + + // ----- Bob receives userOut per token ----- assertEq( - balancesAfter.bobTokens[daiIdx] - balancesBefore.bobTokens[daiIdx], - amountOut, + v.balancesAfter.bobTokens[daiIdx] - v.balancesBefore.bobTokens[daiIdx], + v.userOut, "bob's DAI amount is wrong" ); assertEq( - balancesAfter.bobTokens[usdcIdx] - balancesBefore.bobTokens[usdcIdx], - amountOut, + v.balancesAfter.bobTokens[usdcIdx] - v.balancesBefore.bobTokens[usdcIdx], + v.userOut, "bob's USDC amount is wrong" ); - // Pool balances decrease by amountOut. + // ----- Pool reserves decreased by userOut + protocolTake (non-protocol part of the fee was donated to pool) ----- assertEq( - balancesBefore.poolTokens[daiIdx] - balancesAfter.poolTokens[daiIdx], - amountOut, + v.balancesBefore.poolTokens[daiIdx] - v.balancesAfter.poolTokens[daiIdx], + v.netPoolAndVaultDecrease, "Pool's DAI amount is wrong" ); assertEq( - balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], - amountOut, + v.balancesBefore.poolTokens[usdcIdx] - v.balancesAfter.poolTokens[usdcIdx], + v.netPoolAndVaultDecrease, "Pool's USDC amount is wrong" ); - //As the bpt value taken in fees is readded to the pool under the router address, the pool supply should remain the same + // ----- BPT supply decreased by the full amount Bob redeemed; no BPT is parked anywhere ----- assertEq( - balancesBefore.poolSupply - balancesAfter.poolSupply, - bptAmount - balancesAfter.userBpt, + v.balancesBefore.poolSupply - v.balancesAfter.poolSupply, + bptAmount - v.balancesAfter.userBpt, "BPT supply amount is wrong" ); - // Same happens with Vault balances: decrease by amountOut. + // ----- Vault balances mirror pool movement ----- assertEq( - balancesBefore.vaultTokens[daiIdx] - balancesAfter.vaultTokens[daiIdx], - amountOut, + v.balancesBefore.vaultTokens[daiIdx] - v.balancesAfter.vaultTokens[daiIdx], + v.netPoolAndVaultDecrease, "Vault's DAI amount is wrong" ); assertEq( - balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], - amountOut, + v.balancesBefore.vaultTokens[usdcIdx] - v.balancesAfter.vaultTokens[usdcIdx], + v.netPoolAndVaultDecrease, "Vault's USDC amount is wrong" ); - // Hook balances remain unchanged. - assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); - assertEq(balancesBefore.hookTokens[usdcIdx], balancesAfter.hookTokens[usdcIdx], "Hook's USDC amount is wrong"); + // ----- Hook balances unchanged ----- + assertEq(v.balancesBefore.hookTokens[daiIdx], v.balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong"); + assertEq(v.balancesBefore.hookTokens[usdcIdx], v.balancesAfter.hookTokens[usdcIdx], "Hook's USDC amount is wrong"); - // Router should set all lp data to 0. - //User has extracted deposit, now deposit was deleted and popped from the mapping + // ----- Router clears LP accounting on full exit ----- assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.bptAmount(nftTokenId), 0, "bptAmount mapping should be 0"); - //assertEq(upliftOnlyRouter.startTime(nftTokenId), 0, "startTime mapping should be 0"); + v.nftTokenId = 0; + assertEq(upliftOnlyRouter.nftPool(v.nftTokenId), address(0), "pool mapping should be 0"); - assertEq(upliftOnlyRouter.nftPool(nftTokenId), address(0), "pool mapping should be 0"); + // ----- No BPT left on router or Bob ----- + assertEq(BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), 0, "upliftOnlyRouter should hold no BPT"); + assertEq(v.balancesAfter.bobBpt, 0, "bob should not hold any BPT"); + // ----- Protocol take is paid in base tokens (not BPT) to QuantAMM admin via the Vault ----- + // Each token pays 'protocolTake' to the admin assertEq( - BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), - 0, - "upliftOnlyRouter should hold no BPT" + dai.balanceOf(v.qaAdmin) - v.adminDaiBefore, + v.protocolTake, + "admin DAI payout wrong" ); - assertEq(balancesAfter.bobBpt, 0, "bob should not hold any BPT"); + assertEq( + usdc.balanceOf(v.qaAdmin) - v.adminUsdcBefore, + v.protocolTake, + "admin USDC payout wrong" + ); + + // With the new logic, the protocol no longer holds any BPT after exit + assertEq(v.balancesAfter.userBpt, 0, "quantamm should not hold any BPT"); } + } diff --git a/pkg/pool-hooks/test/foundry/UpliftExampleCodeHawkTests.t.sol b/pkg/pool-hooks/test/foundry/UpliftExampleCodeHawkTests.t.sol new file mode 100644 index 00000000..54214d98 --- /dev/null +++ b/pkg/pool-hooks/test/foundry/UpliftExampleCodeHawkTests.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: SEE LICENSE IN LICENSE +pragma solidity ^0.8.24; + +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; +import { QuantAMMWeightedPool, IQuantAMMWeightedPool } from "pool-quantamm/contracts/QuantAMMWeightedPool.sol"; +import {QuantAMMWeightedPoolFactory} from "pool-quantamm/contracts/QuantAMMWeightedPoolFactory.sol"; +import { UpdateWeightRunner, IUpdateRule } from "pool-quantamm/contracts/UpdateWeightRunner.sol"; +import { MockChainlinkOracle } from "./utils/MockChainlinkOracles.sol"; +import "@balancer-labs/v3-interfaces/contracts/pool-quantamm/OracleWrapper.sol"; +import { IUpdateRule } from "pool-quantamm/contracts/rules/UpdateRule.sol"; +import { MockMomentumRule } from "pool-quantamm/contracts/mock/mockRules/MockMomentumRule.sol"; +import { UpliftOnlyExample } from "../../contracts/hooks-quantamm/UpliftOnlyExample.sol"; +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultAdmin.sol"; +import { PoolRoleAccounts, TokenConfig, HooksConfig } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; +import { IWETH } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/misc/IWETH.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Router } from "@balancer-labs/v3-vault/contracts/Router.sol"; +import {console} from "forge-std/console.sol"; + +contract UpliftExampleCode is BaseVaultTest { // use default dai, usdc, weth and mock oracle + //address daiOnETH = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + //address usdcOnETH = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + //address wethOnETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + uint256 internal daiIdx; + uint256 internal usdcIdx; + + uint256 SWAP_FEE_PERCENTAGE = 10e16; + + address quantAdmin = makeAddr("quantAdmin"); + address owner = makeAddr("owner"); + address poolCreator = makeAddr("poolCreator"); + address liquidityProvider1 = makeAddr("liquidityProvider1"); + address liquidityProvider2 = makeAddr("liquidityProvider2"); + address attacker = makeAddr("attacker"); + //address usdcUsd = 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6; + //address daiUsd = 0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9; + //address ethOracle = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; + + QuantAMMWeightedPool public weightedPool; + QuantAMMWeightedPoolFactory public weightedPoolFactory; + UpdateWeightRunner public updateWeightRunner; + MockChainlinkOracle mockOracledai; + MockChainlinkOracle mockOracleusdc; + MockChainlinkOracle ethOracle; + Router externalRouter; + + UpliftOnlyExample upLifthook; + + function setUp() public override { + vm.warp(block.timestamp + 3600); + mockOracledai = new MockChainlinkOracle(1e18, 0); + mockOracleusdc = new MockChainlinkOracle(1e18, 0); + ethOracle = new MockChainlinkOracle(2000e18, 0); + updateWeightRunner = new UpdateWeightRunner(quantAdmin, address(ethOracle)); + + vm.startPrank(quantAdmin); + updateWeightRunner.addOracle(OracleWrapper(address(mockOracledai))); + updateWeightRunner.addOracle(OracleWrapper(address(mockOracleusdc))); + vm.stopPrank(); + + super.setUp(); + + (daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc)); + + vm.prank(quantAdmin); + updateWeightRunner.setApprovedActionsForPool(pool, 2); + } + + function createHook() internal override returns (address) { + // Create the factory here, because it needs to be deployed after the Vault, but before the hook contract. + weightedPoolFactory = new QuantAMMWeightedPoolFactory(IVault(address(vault)), 365 days, "Factory v1", "Pool v1", address(updateWeightRunner)); + // lp will be the owner of the hook. Only LP is able to set hook fee percentages. + vm.prank(quantAdmin); + upLifthook = new UpliftOnlyExample(IVault(address(vault)), IWETH(weth), IPermit2(permit2), 100, 100, address(updateWeightRunner), "version 1", "lpnft", "LP-NFT"); + return address(upLifthook); + } + + function _createPool( + address[] memory tokens, + string memory label + ) internal override returns (address newPool, bytes memory poolArgs) { + QuantAMMWeightedPoolFactory.CreationNewPoolParams memory poolParams = _createPoolParams(tokens); + + (newPool, poolArgs) = weightedPoolFactory.create(poolParams); + vm.label(newPool, label); + + authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), quantAdmin); + vm.prank(quantAdmin); + vault.setStaticSwapFeePercentage(newPool, SWAP_FEE_PERCENTAGE); + } + + function _createPoolParams(address[] memory tokens) internal returns (QuantAMMWeightedPoolFactory.CreationNewPoolParams memory retParams) { + PoolRoleAccounts memory roleAccounts; + + uint64[] memory lambdas = new uint64[](1); + lambdas[0] = 0.2e18; + + int256[][] memory parameters = new int256[][](1); + parameters[0] = new int256[](1); + parameters[0][0] = 0.2e18; + + address[][] memory oracles = new address[][](2); + oracles[0] = new address[](1); + oracles[0][0] = address(mockOracledai); + oracles[1] = new address[](1); + oracles[1][0] = address(mockOracleusdc); + + uint256[] memory normalizedWeights = new uint256[](2); + normalizedWeights[0] = uint256(0.5e18); + normalizedWeights[1] = uint256(0.5e18); + + IERC20[] memory ierctokens = new IERC20[](2); + for (uint256 i = 0; i < tokens.length; i++) { + ierctokens[i] = IERC20(tokens[i]); + } + + int256[] memory initialWeights = new int256[](2); + initialWeights[0] = 0.5e18; + initialWeights[1] = 0.5e18; + + int256[] memory initialMovingAverages = new int256[](2); + initialMovingAverages[0] = 0.5e18; + initialMovingAverages[1] = 0.5e18; + + int256[] memory initialIntermediateValues = new int256[](2); + initialIntermediateValues[0] = 0.5e18; + initialIntermediateValues[1] = 0.5e18; + + TokenConfig[] memory tokenConfig = vault.buildTokenConfig(ierctokens); + + retParams = QuantAMMWeightedPoolFactory.CreationNewPoolParams( + "Pool With Donation", + "PwD", + tokenConfig, + normalizedWeights, + roleAccounts, + 0.02e18, + address(poolHooksContract), + true, + true, // Do not disable unbalanced add/remove liquidity + 0x0000000000000000000000000000000000000000000000000000000000000000, + initialWeights, + IQuantAMMWeightedPool.PoolSettings( + ierctokens, + IUpdateRule(new MockMomentumRule(owner)), + oracles, + 60, + lambdas, + 0.2e18, + 0.2e18, + 0.3e18, + parameters, + poolCreator + ), + initialMovingAverages, + initialIntermediateValues, + 3600, + 16,//able to set weights + new string[][](0) + ); + } + + function testRemoveLiquidityUplift() public { + addLiquidity(); + + uint256[] memory minAmountsOut = new uint256[](2); + minAmountsOut[0] = 1; + minAmountsOut[1] = 1; + + vm.prank(liquidityProvider1); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + UpliftOnlyExample(payable(poolHooksContract)).removeLiquidityProportional(2e18, minAmountsOut, true, pool); + } + + function addLiquidity() public { + deal(address(dai), liquidityProvider1, 100e18); + deal(address(usdc), liquidityProvider1, 100e18); + + uint256[] memory maxAmountsIn = new uint256[](2); + maxAmountsIn[0] = 2.1e18; + maxAmountsIn[1] = 2.1e18; + uint256 exactBptAmountOut = 2e18; + + vm.startPrank(liquidityProvider1); + IERC20(address(dai)).approve(address(permit2), 100e18); + IERC20(address(usdc)).approve(address(permit2), 100e18); + permit2.approve(address(dai), address(poolHooksContract), 100e18, uint48(block.timestamp)); + permit2.approve(address(usdc), address(poolHooksContract), 100e18, uint48(block.timestamp)); + UpliftOnlyExample(payable(poolHooksContract)).addLiquidityProportional(pool, maxAmountsIn, exactBptAmountOut, false, abi.encodePacked(liquidityProvider1)); + vm.stopPrank(); + + deal(address(dai), liquidityProvider2, 100e18); + deal(address(usdc), liquidityProvider2, 100e18); + + vm.startPrank(liquidityProvider2); + IERC20(address(dai)).approve(address(permit2), 100e18); + IERC20(address(usdc)).approve(address(permit2), 100e18); + permit2.approve(address(dai), address(poolHooksContract), 100e18, uint48(block.timestamp)); + permit2.approve(address(usdc), address(poolHooksContract), 100e18, uint48(block.timestamp)); + UpliftOnlyExample(payable(poolHooksContract)).addLiquidityProportional(pool, maxAmountsIn, exactBptAmountOut, false, abi.encodePacked(liquidityProvider2)); + vm.stopPrank(); + + console.log("Liquidity added"); + } +} \ No newline at end of file diff --git a/pkg/pool-hooks/test/foundry/UpliftExampleFuzz.t.sol b/pkg/pool-hooks/test/foundry/UpliftExampleFuzz.t.sol new file mode 100644 index 00000000..44c9e610 --- /dev/null +++ b/pkg/pool-hooks/test/foundry/UpliftExampleFuzz.t.sol @@ -0,0 +1,1228 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { + LiquidityManagement, + PoolRoleAccounts, + RemoveLiquidityKind, + AfterSwapParams, + SwapKind, + AddLiquidityKind +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; +import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; +import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultAdmin.sol"; +import { IVaultExplorer } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExplorer.sol"; +import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; +import { IVaultMock } from "@balancer-labs/v3-interfaces/contracts/test/IVaultMock.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; + +import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol"; +import { BasicAuthorizerMock } from "@balancer-labs/v3-vault/contracts/test/BasicAuthorizerMock.sol"; +import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { BaseTest } from "@balancer-labs/v3-solidity-utils/test/foundry/utils/BaseTest.sol"; +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; + +import { BatchRouterMock } from "@balancer-labs/v3-vault/contracts/test/BatchRouterMock.sol"; +import { PoolFactoryMock } from "@balancer-labs/v3-vault/contracts/test/PoolFactoryMock.sol"; +import { BalancerPoolToken } from "@balancer-labs/v3-vault/contracts/BalancerPoolToken.sol"; +import { RouterMock } from "@balancer-labs/v3-vault/contracts/test/RouterMock.sol"; +import { PoolMock } from "@balancer-labs/v3-vault/contracts/test/PoolMock.sol"; + +import { MockUpdateWeightRunner } from "pool-quantamm/contracts/mock/MockUpdateWeightRunner.sol"; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { UpliftOnlyExample } from "../../contracts/hooks-quantamm/UpliftOnlyExample.sol"; +import { LPNFT } from "../../contracts/hooks-quantamm/LPNFT.sol"; + +contract UpliftOnlyExampleFuzzTest is BaseVaultTest { + using CastingHelpers for address[]; + using ArrayHelpers for *; + using FixedPoint for uint256; + using Strings for uint256; + using Strings for uint64; + using Strings for uint40; + + uint256 internal daiIdx; + uint256 internal usdcIdx; + + address internal owner; + address internal addr1; + address internal addr2; + + uint64 private constant _MIN_SWAP_FEE_PERCENTAGE = 0.001e16; + uint64 private constant _MAX_SWAP_FEE_PERCENTAGE = 10e16; + uint64 private constant _MAX_UPLIFT_WITHDRAWAL_FEE = 20e16; + uint256 internal bptAmount = 2e3 * 1e18; + + uint256 internal constant DEFAULT_AMP_FACTOR = 200; + + PoolFactoryMock internal factoryMock; + MockUpdateWeightRunner internal updateWeightRunner; + + UpliftOnlyExample internal upliftOnlyRouter; + + function setUp() public virtual override { + BaseTest.setUp(); + (address ownerLocal, address addr1Local, address addr2Local) = (vm.addr(1), vm.addr(2), vm.addr(3)); + owner = ownerLocal; + addr1 = addr1Local; + addr2 = addr2Local; + + vault = deployVaultMock(); + vm.label(address(vault), "vault"); + vaultExtension = IVaultExtension(vault.getVaultExtension()); + vm.label(address(vaultExtension), "vaultExtension"); + vaultAdmin = IVaultAdmin(vault.getVaultAdmin()); + vm.label(address(vaultAdmin), "vaultAdmin"); + authorizer = BasicAuthorizerMock(address(vault.getAuthorizer())); + vm.label(address(authorizer), "authorizer"); + factoryMock = PoolFactoryMock(address(vault.getPoolFactoryMock())); + vm.label(address(factoryMock), "factory"); + router = deployRouterMock(IVault(address(vault)), weth, permit2); + vm.label(address(router), "router"); + batchRouter = deployBatchRouterMock(IVault(address(vault)), weth, permit2); + vm.label(address(batchRouter), "batch router"); + feeController = vault.getProtocolFeeController(); + vm.label(address(feeController), "fee controller"); + + vm.startPrank(address(vaultAdmin)); + updateWeightRunner = new MockUpdateWeightRunner(address(vaultAdmin), address(addr2), true); + vm.label(address(updateWeightRunner), "updateWeightRunner"); + updateWeightRunner.setQuantAMMSwapFeeTake(0); + + vm.stopPrank(); + + vm.startPrank(owner); + upliftOnlyRouter = new UpliftOnlyExample( + IVault(address(vault)), + weth, + permit2, + 200, + 5, + address(updateWeightRunner), + "Uplift LiquidityPosition v1", + "Uplift LiquidityPosition v1", + "Uplift LiquidityPosition v1" + ); + vm.stopPrank(); + vm.label(address(upliftOnlyRouter), "upliftOnlyRouter"); + + poolHooksContract = address(upliftOnlyRouter); + (pool, ) = createPool(); + + for (uint256 i = 0; i < users.length; ++i) { + address user = users[i]; + vm.startPrank(user); + approveForSender(); + vm.stopPrank(); + } + if (pool != address(0)) { + approveForPool(IERC20(pool)); + } + + initPool(); + + (daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc)); + } + + function approveForSender() internal override { + for (uint256 i = 0; i < tokens.length; ++i) { + tokens[i].approve(address(permit2), type(uint256).max); + permit2.approve(address(tokens[i]), address(router), type(uint160).max, type(uint48).max); + permit2.approve(address(tokens[i]), address(batchRouter), type(uint160).max, type(uint48).max); + permit2.approve(address(tokens[i]), address(upliftOnlyRouter), type(uint160).max, type(uint48).max); + } + } + + // Overrides approval to include upliftOnlyRouter. + function approveForPool(IERC20 bpt) internal override { + for (uint256 i = 0; i < users.length; ++i) { + vm.startPrank(users[i]); + + bpt.approve(address(router), type(uint256).max); + bpt.approve(address(batchRouter), type(uint256).max); + bpt.approve(address(upliftOnlyRouter), type(uint256).max); + + IERC20(bpt).approve(address(permit2), type(uint256).max); + permit2.approve(address(bpt), address(router), type(uint160).max, type(uint48).max); + permit2.approve(address(bpt), address(batchRouter), type(uint160).max, type(uint48).max); + permit2.approve(address(bpt), address(upliftOnlyRouter), type(uint160).max, type(uint48).max); + + vm.stopPrank(); + } + } + + // Overrides pool creation to set liquidityManagement (disables unbalanced liquidity). + function _createPool( + address[] memory tokens, + string memory label + ) internal override returns (address newPool, bytes memory poolArgs) { + string memory name = "Uplift Pool"; + string memory symbol = "Uplift Pool"; + + newPool = address(deployPoolMock(IVault(address(vault)), name, symbol)); + vm.label(newPool, label); + int256[] memory prices = new int256[](tokens.length); + for (uint256 i = 0; i < tokens.length; ++i) { + prices[i] = int256(i) * 1e18; + } + updateWeightRunner.setMockPrices(address(newPool), prices); + + PoolRoleAccounts memory roleAccounts; + roleAccounts.poolCreator = lp; + + LiquidityManagement memory liquidityManagement; + liquidityManagement.disableUnbalancedLiquidity = true; + liquidityManagement.enableDonation = true; + + factoryMock.registerPool( + newPool, + vault.buildTokenConfig(tokens.asIERC20()), + roleAccounts, + poolHooksContract, + liquidityManagement + ); + + poolArgs = abi.encode(vault, name, symbol); + } + + function testFuzz_AddLiquidity(uint96 fuzzBptOut) public { + uint256 poolSupply = BalancerPoolToken(pool).totalSupply(); + + uint256 maxMint = poolSupply == 0 ? type(uint96).max : poolSupply / 10; + uint256 bptOut = bound(uint256(fuzzBptOut), 1, maxMint); + + uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + + BaseVaultTest.Balances memory before = getBalances(bob); + uint256 supplyBefore = poolSupply; + + bytes4 AMOUNT_IN_ABOVE_MAX_SELECTOR = bytes4(keccak256("AmountInAboveMax(address,uint256,uint256)")); + uint256[] memory amountsIn; + + vm.startPrank(bob); + (bool ok, bytes memory ret) = address(upliftOnlyRouter).call( + abi.encodeWithSelector( + upliftOnlyRouter.addLiquidityProportional.selector, + pool, + maxAmountsIn, + bptOut, + false, + bytes("") + ) + ); + vm.stopPrank(); + + if (!ok) { + bytes4 sel; + assembly { + sel := mload(add(ret, 32)) + } + assertEq(sel, AMOUNT_IN_ABOVE_MAX_SELECTOR, "unexpected revert"); + return; + } + + amountsIn = abi.decode(ret, (uint256[])); + + BaseVaultTest.Balances memory after_ = getBalances(bob); + uint256 supplyAfter = BalancerPoolToken(pool).totalSupply(); + + assertEq(before.bobTokens[daiIdx] - after_.bobTokens[daiIdx], amountsIn[daiIdx], "DAI spent mismatch"); + assertEq(before.bobTokens[usdcIdx] - after_.bobTokens[usdcIdx], amountsIn[usdcIdx], "USDC spent mismatch"); + + assertEq(supplyAfter - supplyBefore, bptOut, "BPT minted mismatch"); + + UpliftOnlyExample.FeeData memory fd = upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0]; + + assertEq(fd.amount, bptOut, "recorded BPT amount wrong"); + assertEq(fd.upliftFeeBps, upliftOnlyRouter.upliftFeeBps(), "upliftFeeBps wrong"); + assertEq(upliftOnlyRouter.nftPool(fd.tokenID), pool, "nftPool lookup wrong"); + + assertEq(BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), bptOut, "router BPT balance wrong"); + assertEq(after_.bobBpt, 0, "Bob should hold no BPT (NFT represents position)"); + } + + function testFuzz_DepositLimit(uint8 depositCountFuzz, uint96 bptPerDepositFuzz) public { + uint256 depositCount = bound(uint256(depositCountFuzz), 1, 120); + + uint256 poolSupply = BalancerPoolToken(pool).totalSupply(); + uint256 bptPerDeposit = bound(uint256(bptPerDepositFuzz), 1, poolSupply == 0 ? 1e18 : poolSupply / 1000); + + uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + + vm.startPrank(bob); + + for (uint256 i; i < depositCount; ++i) { + if (i >= 100) { + vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.TooManyDeposits.selector, pool, bob)); + } + + upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptPerDeposit, false, bytes("")); + + if (i >= 100) break; + + skip(2 seconds); + } + + vm.stopPrank(); + + uint256 expectedDeposits = depositCount > 100 ? 100 : depositCount; + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, expectedDeposits, "FeeData length mismatch"); + } + + function testFuzz_TransferDepositsAtRandom(uint256 seed, uint256 depositLength) public { + uint256 depositBound = bound(depositLength, 1, 2); + /** + * This can be changed to the max 98 however it takes some time! + * uint256 depositBound = bound(depositLength, 1, 98); + * [PASS] testTransferDepositsAtRandom(uint256,uint256) (runs: 10002, μ: 119097137, ~: 78857000) + Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1233.99s (1233.98s CPU time) + + Ran 1 test suite in 1234.00s (1233.99s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) + * + */ + uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + vm.startPrank(bob); + uint256 bptAmountDeposit = bptAmount / 150; + uint256[] memory tokenIndexArray = new uint256[](depositBound); + for (uint256 i = 0; i < depositBound; i++) { + tokenIndexArray[i] = i + 1; + upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmountDeposit, false, bytes("")); + skip(1 days); + } + vm.stopPrank(); + + // Shuffle the array using the seed + uint[] memory shuffledArray = shuffle(tokenIndexArray, seed); + + LPNFT lpNft = upliftOnlyRouter.lpNFT(); + + for (uint256 i = 0; i < depositBound; i++) { + vm.startPrank(bob); + + lpNft.transferFrom(bob, alice, shuffledArray[i]); + UpliftOnlyExample.FeeData[] memory aliceFees = upliftOnlyRouter.getUserPoolFeeData(pool, alice); + UpliftOnlyExample.FeeData[] memory bobFees = upliftOnlyRouter.getUserPoolFeeData(pool, bob); + + assertEq(aliceFees.length, i + 1, "alice should have all transfers"); + assertEq( + aliceFees[aliceFees.length - 1].tokenID, + shuffledArray[i], + "last transferred tokenId should match" + ); + + assertEq(bobFees.length, depositBound - (i + 1), "bob should have all transferred last"); + + uint[] memory orderedArrayWithoutShuffled = new uint[](depositBound - (i + 1)); + uint lastPopulatedIndex = 0; + for (uint256 j = 1; j <= depositBound; j++) { + bool inPreviousShuffled = false; + for (uint256 k = 0; k < i + 1; k++) { + if (shuffledArray[k] == j) { + inPreviousShuffled = true; + break; + } + } + if (!inPreviousShuffled) { + orderedArrayWithoutShuffled[lastPopulatedIndex] = j; + lastPopulatedIndex++; + } + } + + for (uint256 j = 0; j < bobFees.length; j++) { + assertEq(bobFees[j].tokenID, orderedArrayWithoutShuffled[j], "bob should have ordered tokenID"); + } + + vm.stopPrank(); + } + } + + //Function to generate a shuffled array of unique uints between 0 and 10 + function shuffle(uint[] memory array, uint seed) internal pure returns (uint[] memory) { + uint length = array.length; + for (uint i = length - 1; i > 0; i--) { + uint j = seed % (i + 1); // Pseudo-random index based on the seed + (array[i], array[j]) = (array[j], array[i]); // Swap elements + seed /= (i + 1); // Adjust seed to vary indices in next iteration + } + return array; + } + + function _grossTokenOut( + uint256 poolReservesBefore, + uint256 poolSupplyBefore, + uint256 bptIn + ) internal pure returns (uint256) { + return (poolReservesBefore * bptIn) / poolSupplyBefore; + } + + /// @dev Net amount after charging `feeBps` (0 … 10_000). + function _netAfterFee(uint256 grossAmount, uint256 feeBps) internal pure returns (uint256) { + return grossAmount - grossAmount.mulDown(feeBps); + } + + function testFuzz_removeLiquidity_noProtocolTake(uint16 withdrawalFeeBps) public { + _runFuzz(withdrawalFeeBps, 0); + } + + function testFuzz_removeLiquidity_withProtocolTake(uint16 withdrawalFeeBps, uint256 protocolTakeE18) public { + _runFuzz(withdrawalFeeBps, protocolTakeE18); + } + + struct FuzzParams { + uint256 grossDai; + uint256 grossUsdc; + uint256 expectedDai; + uint256 expectedUsdc; + uint256 expectedNet; + uint256 upliftBpt; + uint256 protoShare; + uint256 routerKeep; + uint256 expectedRouterBpt; + } + + function _runFuzz(uint64 withdrawalFeeBps, uint256 protocolTakeE18) internal { + withdrawalFeeBps = uint64(bound(withdrawalFeeBps, 5, 500)); + if (protocolTakeE18 > 0) { + protocolTakeE18 = uint256(bound(protocolTakeE18, 5, 9999)) * 1e14; //realistically 1% admin take is lowest possible + } + + vm.prank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMUpliftFeeTake(protocolTakeE18); + vm.stopPrank(); + + vm.startPrank(owner); + upliftOnlyRouter = new UpliftOnlyExample( + IVault(address(vault)), + weth, + permit2, + withdrawalFeeBps * 1e14, // convert to e18 + 5e14, + address(updateWeightRunner), + "Uplift LiquidityPosition v1", + "Uplift LiquidityPosition v1", + "Uplift LiquidityPosition v1" + ); + vm.stopPrank(); + + poolHooksContract = address(upliftOnlyRouter); + (pool, ) = createPool(); + _approveAllUsers(); + initPool(); + + vm.prank(owner); + UpliftOnlyExample(payable(poolHooksContract)).transferOwnership(poolHooksContract); /* Bob’s current balances */ + + uint256 bobDai = dai.balanceOf(bob); + uint256 bobUsdc = usdc.balanceOf(bob); + + uint256[] memory maxIn = [bobDai + 5, bobUsdc + 5].toMemoryArray(); + + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, maxIn, bptAmount, false, ""); + vm.stopPrank(); + + uint256 withdrawBpt = bptAmount / 2; + uint256[] memory minsOut = [uint256(0), uint256(0)].toMemoryArray(); + + BaseVaultTest.Balances memory before = getBalances(bob); + + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + + vm.prank(bob); + upliftOnlyRouter.removeLiquidityProportional(withdrawBpt, minsOut, false, pool); + vm.stopPrank(); + + BaseVaultTest.Balances memory after_ = getBalances(updateWeightRunner.getQuantAMMAdmin()); + + FuzzParams memory params; + + params.grossDai = _grossTokenOut(before.poolTokens[daiIdx], before.poolSupply, withdrawBpt); + params.grossUsdc = _grossTokenOut(before.poolTokens[usdcIdx], before.poolSupply, withdrawBpt); + + uint256 feeBps = upliftOnlyRouter.minWithdrawalFeeBps(); // ← always 5 + params.expectedDai = _netAfterFee(params.grossDai, feeBps); + params.expectedUsdc = _netAfterFee(params.grossUsdc, feeBps); + + assertApproxEqAbs( + after_.bobTokens[daiIdx] - before.bobTokens[daiIdx], + params.expectedDai, + 1, + "bob DAI mismatch" + ); + + assertApproxEqAbs( + after_.bobTokens[usdcIdx] - before.bobTokens[usdcIdx], + params.expectedUsdc, + 1, + "bob USDC mismatch" + ); + + params.expectedNet = _netAfterFee(withdrawBpt, feeBps); + + params.upliftBpt = withdrawBpt - params.expectedNet; + + assertEq(before.poolSupply - after_.poolSupply, withdrawBpt, "pool supply mismatch"); + + params.expectedRouterBpt = bptAmount - withdrawBpt; + assertEq(BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), params.expectedRouterBpt, "router BPT"); + + assertEq(after_.userBpt, params.protoShare, "admin BPT"); + + assertEq(after_.bobBpt, 0, "bob BPT"); + assertEq(BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), params.expectedRouterBpt, "router BPT"); + } + + function _approveAllUsers() internal { + for (uint256 i; i < users.length; ++i) { + vm.startPrank(users[i]); + approveForSender(); + vm.stopPrank(); + } + if (pool != address(0)) { + approveForPool(IERC20(pool)); + } + } + + function testFuzz_FeeSwapExactIn(uint256 swapAmount, uint64 hookFeePercentage) public { + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); + + hookFeePercentage = uint64(bound(hookFeePercentage, _MIN_SWAP_FEE_PERCENTAGE, _MAX_SWAP_FEE_PERCENTAGE)); + + vm.expectEmit(); + emit UpliftOnlyExample.HookSwapFeePercentageChanged(poolHooksContract, hookFeePercentage); + + vm.prank(owner); + UpliftOnlyExample(payable(poolHooksContract)).setHookSwapFeePercentage(hookFeePercentage); + uint256 hookFee = swapAmount.mulUp(hookFeePercentage); + + BaseVaultTest.Balances memory balancesBefore = getBalances(owner); + + vm.prank(bob); + vm.expectCall( + address(poolHooksContract), + abi.encodeCall( + IHooks.onAfterSwap, + AfterSwapParams({ + kind: SwapKind.EXACT_IN, + tokenIn: dai, + tokenOut: usdc, + amountInScaled18: swapAmount, + amountOutScaled18: swapAmount, + tokenInBalanceScaled18: poolInitAmount + swapAmount, + tokenOutBalanceScaled18: poolInitAmount - swapAmount, + amountCalculatedScaled18: swapAmount, + amountCalculatedRaw: swapAmount, + router: address(router), + pool: pool, + userData: bytes("") + }) + ) + ); + + if (hookFee > 0) { + vm.expectEmit(); + emit UpliftOnlyExample.SwapHookFeeCharged(poolHooksContract, IERC20(usdc), hookFee); + } + + router.swapSingleTokenExactIn(address(pool), dai, usdc, swapAmount, 0, MAX_UINT256, false, bytes("")); + + BaseVaultTest.Balances memory balancesAfter = getBalances(owner); + + assertEq( + balancesBefore.bobTokens[daiIdx] - balancesAfter.bobTokens[daiIdx], + swapAmount, + "Bob DAI balance is wrong" + ); + assertEq(balancesBefore.userTokens[daiIdx], balancesAfter.userTokens[daiIdx], "Hook DAI balance is wrong"); + assertEq( + balancesAfter.bobTokens[usdcIdx] - balancesBefore.bobTokens[usdcIdx], + swapAmount - hookFee, + "Bob USDC balance is wrong" + ); + assertEq( + balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], + hookFee, + "Hook USDC balance is wrong" + ); + + _checkPoolAndVaultBalancesAfterSwap(balancesBefore, balancesAfter, swapAmount); + } + + function testFuzz_FeeSwapExactOut(uint256 swapAmount, uint64 hookFeePercentage) public { + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); + + hookFeePercentage = uint64(bound(hookFeePercentage, _MIN_SWAP_FEE_PERCENTAGE, _MAX_SWAP_FEE_PERCENTAGE)); + + vm.expectEmit(); + emit UpliftOnlyExample.HookSwapFeePercentageChanged(poolHooksContract, hookFeePercentage); + + vm.prank(owner); + UpliftOnlyExample(payable(poolHooksContract)).setHookSwapFeePercentage(hookFeePercentage); + uint256 hookFee = swapAmount.mulUp(hookFeePercentage); + + BaseVaultTest.Balances memory balancesBefore = getBalances(owner); + + vm.prank(bob); + vm.expectCall( + address(poolHooksContract), + abi.encodeCall( + IHooks.onAfterSwap, + AfterSwapParams({ + kind: SwapKind.EXACT_OUT, + tokenIn: dai, + tokenOut: usdc, + amountInScaled18: swapAmount, + amountOutScaled18: swapAmount, + tokenInBalanceScaled18: poolInitAmount + swapAmount, + tokenOutBalanceScaled18: poolInitAmount - swapAmount, + amountCalculatedScaled18: swapAmount, + amountCalculatedRaw: swapAmount, + router: address(router), + pool: pool, + userData: bytes("") + }) + ) + ); + + if (hookFee > 0) { + vm.expectEmit(); + emit UpliftOnlyExample.SwapHookFeeCharged(poolHooksContract, IERC20(dai), hookFee); + } + + router.swapSingleTokenExactOut( + address(pool), + dai, + usdc, + swapAmount, + MAX_UINT256, + MAX_UINT256, + false, + bytes("") + ); + + BaseVaultTest.Balances memory balancesAfter = getBalances(owner); + + assertEq( + balancesAfter.bobTokens[usdcIdx] - balancesBefore.bobTokens[usdcIdx], + swapAmount, + "Bob USDC balance is wrong" + ); + assertEq(balancesBefore.userTokens[usdcIdx], balancesAfter.userTokens[usdcIdx], "Hook USDC balance is wrong"); + assertEq( + balancesBefore.bobTokens[daiIdx] - balancesAfter.bobTokens[daiIdx], + swapAmount + hookFee, + "Bob DAI balance is wrong" + ); + assertEq( + balancesAfter.userTokens[daiIdx] - balancesBefore.userTokens[daiIdx], + hookFee, + "Hook DAI balance is wrong" + ); + + _checkPoolAndVaultBalancesAfterSwap(balancesBefore, balancesAfter, swapAmount); + } + + function _checkPoolAndVaultBalancesAfterSwap( + BaseVaultTest.Balances memory balancesBefore, + BaseVaultTest.Balances memory balancesAfter, + uint256 poolBalanceChange + ) private view { + assertEq( + balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], + poolBalanceChange, + "Pool DAI balance is wrong" + ); + assertEq( + balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], + poolBalanceChange, + "Pool USDC balance is wrong" + ); + assertEq( + balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx], + poolBalanceChange, + "Vault DAI balance is wrong" + ); + assertEq( + balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], + poolBalanceChange, + "Vault USDC balance is wrong" + ); + } + + function testFuzz_removeLiquidityNegativePriceChange_noProtocolTake(uint16 withdrawalFeeBps, uint64 minFee) public { + _runFuzzNegative(withdrawalFeeBps, 0, minFee); + } + + function testFuzz_removeLiquidityNegativePriceChange_withProtocolTake( + uint16 withdrawalFeeBps, + uint256 protocolTakeE18, + uint64 minFee + ) public { + _runFuzzNegative(withdrawalFeeBps, protocolTakeE18, minFee); + } + + struct FuzzNegativeParams { + uint256 amountOut; // per-token proportional amount out + uint256 hookFee; // exit fee charged by the hook (per token) + uint256 adminFeePerToken; // protocol take paid out in base tokens (per token) + uint256 expectedBobDelta; // what Bob receives per token + uint256 expectedPoolVaultDelta; // what the Pool/Vault lose per token + address admin; + BaseVaultTest.Balances beforeBalances; + BaseVaultTest.Balances afterBalances; + uint256 adminDaiBefore; + uint256 adminUsdcBefore; + uint256 adminDaiAfter; + uint256 adminUsdcAfter; + uint64 exitFeePctE18; + uint256 depositValue; + } + + function _runFuzzNegative(uint64 withdrawalFeeBps, uint256 protocolTakeE18, uint64 minFee) internal { + minFee = uint64(bound(minFee, 5, 100)); + withdrawalFeeBps = uint64(bound(withdrawalFeeBps, 5, 500)); + if (protocolTakeE18 > 0) { + protocolTakeE18 = uint256(bound(protocolTakeE18, 5, 9999)) * 1e14; + } + + vm.prank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMUpliftFeeTake(protocolTakeE18); + vm.stopPrank(); + + vm.startPrank(owner); + upliftOnlyRouter = new UpliftOnlyExample( + IVault(address(vault)), + weth, + permit2, + withdrawalFeeBps * 1e14, + 5e14, + address(updateWeightRunner), + "Uplift LiquidityPosition v1", + "Uplift LiquidityPosition v1", + "Uplift LiquidityPosition v1" + ); + vm.stopPrank(); + + poolHooksContract = address(upliftOnlyRouter); + (pool, ) = createPool(); + _approveAllUsers(); + initPool(); + + vm.prank(owner); + UpliftOnlyExample(payable(poolHooksContract)).transferOwnership(poolHooksContract); + + uint256 bobDai = dai.balanceOf(bob); + uint256 bobUsdc = usdc.balanceOf(bob); + uint256[] memory maxIn = [bobDai + 5, bobUsdc + 5].toMemoryArray(); + + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, maxIn, bptAmount, false, ""); + vm.stopPrank(); + + int256[] memory prices = new int256[](tokens.length); + for (uint256 i; i < tokens.length; ++i) { + prices[i] = (int256(i) * 1e18) / 2; + } + updateWeightRunner.setMockPrices(pool, prices); + + uint256[] memory minsOut = [uint256(0), uint256(0)].toMemoryArray(); + + FuzzNegativeParams memory p; + p.admin = updateWeightRunner.getQuantAMMAdmin(); + p.beforeBalances = getBalances(p.admin); + p.adminDaiBefore = dai.balanceOf(p.admin); + p.adminUsdcBefore = usdc.balanceOf(p.admin); + + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + + vm.prank(bob); + upliftOnlyRouter.removeLiquidityProportional(bptAmount, minsOut, false, pool); + vm.stopPrank(); + + p.afterBalances = getBalances(p.admin); + p.adminDaiAfter = dai.balanceOf(p.admin); + p.adminUsdcAfter = usdc.balanceOf(p.admin); + + p.amountOut = bptAmount / 2; + p.exitFeePctE18 = upliftOnlyRouter.minWithdrawalFeeBps(); + p.hookFee = p.amountOut.mulDown(p.exitFeePctE18); + p.depositValue = 0.5e18; + p.adminFeePerToken = p.depositValue.mulDown(protocolTakeE18); + p.expectedBobDelta = p.amountOut - p.hookFee; + p.expectedPoolVaultDelta = p.expectedBobDelta + p.adminFeePerToken; + + assertEq( + p.afterBalances.bobTokens[daiIdx] - p.beforeBalances.bobTokens[daiIdx], + p.expectedBobDelta, + "bob DAI wrong" + ); + assertEq( + p.afterBalances.bobTokens[usdcIdx] - p.beforeBalances.bobTokens[usdcIdx], + p.expectedBobDelta, + "bob USDC wrong" + ); + + assertEq( + p.beforeBalances.poolTokens[daiIdx] - p.afterBalances.poolTokens[daiIdx], + p.expectedPoolVaultDelta, + "pool DAI wrong" + ); + assertEq( + p.beforeBalances.poolTokens[usdcIdx] - p.afterBalances.poolTokens[usdcIdx], + p.expectedPoolVaultDelta, + "pool USDC wrong" + ); + + assertEq( + p.beforeBalances.vaultTokens[daiIdx] - p.afterBalances.vaultTokens[daiIdx], + p.expectedPoolVaultDelta, + "vault DAI wrong" + ); + assertEq( + p.beforeBalances.vaultTokens[usdcIdx] - p.afterBalances.vaultTokens[usdcIdx], + p.expectedPoolVaultDelta, + "vault USDC wrong" + ); + + assertEq(p.beforeBalances.hookTokens[daiIdx], p.afterBalances.hookTokens[daiIdx], "hook DAI wrong"); + assertEq(p.beforeBalances.hookTokens[usdcIdx], p.afterBalances.hookTokens[usdcIdx], "hook USDC wrong"); + + assertEq( + p.beforeBalances.poolSupply - p.afterBalances.poolSupply, + bptAmount - p.afterBalances.userBpt, + "pool supply wrong" + ); + + assertEq(BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), 0, "router BPT wrong"); + assertEq(p.afterBalances.bobBpt, 0, "bob still holds BPT"); + + assertEq(p.adminDaiAfter - p.adminDaiBefore, p.adminFeePerToken, "admin DAI fee wrong"); + assertEq(p.adminUsdcAfter - p.adminUsdcBefore, p.adminFeePerToken, "admin USDC fee wrong"); + } + + /* ──────────────────────────── FUZZ: POSITIVE P&L ─────────────────────────── */ + + function testFuzz_removeLiquidityPositive_noProtocolTake( + uint64 withdrawalFeeBps_, + uint256 priceMulE18_, + uint64 minFee + ) public { + _runPositiveFuzz(withdrawalFeeBps_, 0, priceMulE18_, minFee); + } + + function testFuzz_removeLiquidityPositive_withProtocolTake( + uint64 withdrawalFeeBps_, + uint256 protocolTake, + uint256 priceMulE18_, + uint64 minFee + ) public { + _runPositiveFuzz(withdrawalFeeBps_, protocolTake, priceMulE18_, minFee); + } + + struct FuzzPositiveParams { + uint64 minFee; + uint64 withdrawalFeeBpsBound; + uint256 protocolTake; + uint256 priceMulE18; + address observer; + address admin; + uint256 upliftFeePctE18; + uint256 minFeePctE18; + uint256 effectiveFeePctE18; + uint256 feeAmountTotal; + uint256 feePercentageE18; + uint256 tokensLen; + uint256 amountOut; // per-token gross + uint256 hookFeeTokens; // per-token hook fee + uint256 adminFeeTokens; // per-token (base tokens, NOT BPT) + uint256 adminMintedBpt; + uint256 adminDaiBefore; + uint256 adminUsdcBefore; + uint256 adminDaiAfter; + uint256 adminUsdcAfter; + uint256 bobReceivesPerToken; + uint256 readdToPool; + uint256 netPoolDecreasePerToken; + uint256[] maxIn; + uint256[] minsOut; + uint256[] amountsOut; + int256[] prices; + BaseVaultTest.Balances beforeBalances; + BaseVaultTest.Balances afterBalances; + } + + function _runPositiveFuzz( + uint64 withdrawalFeeBps_, + uint256 protocolTake, + uint256 priceMulE18_, + uint64 minFee + ) internal { + FuzzPositiveParams memory p; + + // ----------------------- + // Bounds & config + // ----------------------- + p.minFee = uint64(bound(minFee, 5, 100)); + p.withdrawalFeeBpsBound = uint64(bound(withdrawalFeeBps_, p.minFee + 1, 500)); + + if (protocolTake > 0) { + p.protocolTake = uint256(bound(protocolTake, p.minFee, 9999)) * 1e14; + } else { + p.protocolTake = 0; + } + + vm.prank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMUpliftFeeTake(p.protocolTake); + vm.stopPrank(); + + p.priceMulE18 = bound(priceMulE18_, 1e18, 10_000e18); + + vm.prank(owner); + upliftOnlyRouter = new UpliftOnlyExample( + IVault(address(vault)), + weth, + permit2, + p.withdrawalFeeBpsBound * 1e14, // upliftFeeBps (1e18-scaled) + p.minFee * 1e14, // minWithdrawalFeeBps (1e18-scaled) + address(updateWeightRunner), + "Uplift LP v1", + "Uplift LP v1", + "Uplift LP v1" + ); + vm.stopPrank(); + + poolHooksContract = address(upliftOnlyRouter); + (pool, ) = createPool(); + _approveAllUsers(); + initPool(); + + // Bob adds liquidity + p.maxIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, p.maxIn, bptAmount, false, ""); + vm.stopPrank(); + + // Price uplift + p.prices = new int256[](tokens.length); + for (uint256 i; i < tokens.length; ++i) { + p.prices[i] = int256(i) * int256(p.priceMulE18); + } + updateWeightRunner.setMockPrices(pool, p.prices); + + // Remove with no mins + p.minsOut = [uint256(0), uint256(0)].toMemoryArray(); + + // Observer (unchanged): if protocolTake==0 observe Bob, else observe admin + p.observer = p.protocolTake == 0 ? bob : updateWeightRunner.getQuantAMMAdmin(); + + // Track balances before + p.beforeBalances = getBalances(p.observer); + p.admin = updateWeightRunner.getQuantAMMAdmin(); + p.adminDaiBefore = dai.balanceOf(p.admin); + p.adminUsdcBefore = usdc.balanceOf(p.admin); + + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + + // Bob exits + vm.prank(bob); + p.amountsOut = upliftOnlyRouter.removeLiquidityProportional(bptAmount, p.minsOut, false, pool); + vm.stopPrank(); + + // Track balances after + p.afterBalances = getBalances(p.observer); + p.adminDaiAfter = dai.balanceOf(p.admin); + p.adminUsdcAfter = usdc.balanceOf(p.admin); + + // ----------------------- + // Math replication (logs) + // ----------------------- + p.upliftFeePctE18 = (p.priceMulE18 - 1e18).mulUp(p.withdrawalFeeBpsBound * 1e14).divDown(p.priceMulE18); + console.log("upliftFeePctE18"); + console.log(uint256(p.upliftFeePctE18).toString()); + + p.minFeePctE18 = uint256(upliftOnlyRouter.minWithdrawalFeeBps()); + console.log("p.minFeePctE18"); + console.log(uint256(p.minFeePctE18).toString()); + + p.effectiveFeePctE18 = p.upliftFeePctE18 > p.minFeePctE18 ? p.upliftFeePctE18 : p.minFeePctE18; + console.log("p.effectiveFeePctE18"); + console.log(uint256(p.effectiveFeePctE18).toString()); + + p.amountOut = bptAmount / 2; + console.log("p.amountOut"); + console.log(uint256(p.amountOut).toString()); + + p.tokensLen = tokens.length; // 2 + p.feeAmountTotal = p.amountOut.mulDown(p.effectiveFeePctE18) * p.tokensLen; + console.log("p.feeAmountTotal"); + console.log(uint256(p.feeAmountTotal).toString()); + + p.feePercentageE18 = p.feeAmountTotal.divDown(bptAmount); + console.log("p.feePercentageE18"); + console.log(uint256(p.feePercentageE18).toString()); + + p.hookFeeTokens = p.amountOut.mulUp(p.feePercentageE18); + console.log("p.hookFeeTokens"); + console.log(uint256(p.hookFeeTokens).toString()); + + p.adminFeeTokens = p.hookFeeTokens.mulDown(p.protocolTake); + console.log("p.adminFeeTokens"); + console.log(uint256(p.adminFeeTokens).toString()); + + p.bobReceivesPerToken = p.amountOut - p.hookFeeTokens; + console.log("p.bobReceivesPerToken"); + console.log(uint256(p.bobReceivesPerToken).toString()); + + p.readdToPool = p.hookFeeTokens - p.adminFeeTokens; + console.log("p.readdToPool"); + console.log(uint256(p.readdToPool).toString()); + + p.netPoolDecreasePerToken = p.amountOut - p.readdToPool; + console.log("p.netPoolDecreasePerToken"); + console.log(uint256(p.netPoolDecreasePerToken).toString()); + + // Admin minted BPT (if any) + p.adminMintedBpt = p.afterBalances.userBpt > p.beforeBalances.userBpt + ? (p.afterBalances.userBpt - p.beforeBalances.userBpt) + : 0; + + // ----------------------- + // Assertions (mirrors unit test intent) + // ----------------------- + + // Bob gets more of each token after withdraw under positive uplift + uint256 bobDaiDelta = p.afterBalances.bobTokens[daiIdx] - p.beforeBalances.bobTokens[daiIdx]; + uint256 bobUsdcDelta = p.afterBalances.bobTokens[usdcIdx] - p.beforeBalances.bobTokens[usdcIdx]; + assertGt(bobDaiDelta, 0, "bob DAI should increase after withdraw"); + assertGt(bobUsdcDelta, 0, "bob USDC should increase after withdraw"); + + // Pool/vault reserves decrease + uint256 poolDaiDelta = p.beforeBalances.poolTokens[daiIdx] - p.afterBalances.poolTokens[daiIdx]; + uint256 poolUsdcDelta = p.beforeBalances.poolTokens[usdcIdx] - p.afterBalances.poolTokens[usdcIdx]; + assertGt(poolDaiDelta, 0, "pool DAI should decrease"); + assertGt(poolUsdcDelta, 0, "pool USDC should decrease"); + + uint256 vaultDaiDelta = p.beforeBalances.vaultTokens[daiIdx] - p.afterBalances.vaultTokens[daiIdx]; + uint256 vaultUsdcDelta = p.beforeBalances.vaultTokens[usdcIdx] - p.afterBalances.vaultTokens[usdcIdx]; + assertGt(vaultDaiDelta, 0, "vault DAI should decrease"); + assertGt(vaultUsdcDelta, 0, "vault USDC should decrease"); + + // Admin tokens: no change when protocolTake==0; increase when protocolTake>0 + if (p.protocolTake == 0) { + assertEq(p.adminDaiAfter, p.adminDaiBefore, "admin DAI should not change when protocolTake=0"); + assertEq(p.adminUsdcAfter, p.adminUsdcBefore, "admin USDC should not change when protocolTake=0"); + } else { + assertGt(p.adminDaiAfter, p.adminDaiBefore, "admin DAI should increase when protocolTake>0"); + assertGt(p.adminUsdcAfter, p.adminUsdcBefore, "admin USDC should increase when protocolTake>0"); + } + + // Total BPT supply change ≈ Bob's burn minus any transient admin mint + assertApproxEqAbs( + p.beforeBalances.poolSupply - p.afterBalances.poolSupply, + bptAmount - p.adminMintedBpt, + 2, + "pool supply" + ); + + // Router holds no BPT; Bob fully exited + assertEq(BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)), 0, "router holds BPT"); + assertEq(p.afterBalances.bobBpt, 0, "bob BPT"); + + // 🔁 Key fix: admin should end with **zero** BPT (they get paid in tokens; + // any minted BPT is burned/withdrawn during the flow), mirroring the unit test. + uint256 adminBptFinal = IERC20(pool).balanceOf(p.admin); + assertEq(adminBptFinal, 0, "admin BPT should be 0 after withdraw"); + } + + function testSetHookFeeOwnerPass(uint64 poolHookAmount) public { + uint64 boundFeeAmount = uint64(bound(poolHookAmount, _MIN_SWAP_FEE_PERCENTAGE, _MAX_SWAP_FEE_PERCENTAGE)); + vm.expectEmit(); + emit UpliftOnlyExample.HookSwapFeePercentageChanged(poolHooksContract, boundFeeAmount); + vm.startPrank(owner); + upliftOnlyRouter.setHookSwapFeePercentage(boundFeeAmount); + vm.stopPrank(); + } + + function testSetHookPassSmallerThanMinimumFail(uint64 poolHookAmount) public { + uint64 boundFeeAmount = uint64(bound(poolHookAmount, 0, _MIN_SWAP_FEE_PERCENTAGE - 1)); + + vm.startPrank(owner); + vm.expectRevert("Below _MIN_SWAP_FEE_PERCENTAGE"); + upliftOnlyRouter.setHookSwapFeePercentage(boundFeeAmount); + vm.stopPrank(); + } + + function testSetHookPassGreaterThanMaxFail(uint64 poolHookAmount) public { + uint64 boundFeeAmount = uint64( + bound(poolHookAmount, uint64(_MAX_SWAP_FEE_PERCENTAGE) + 1, uint64(type(uint64).max)) + ); + + vm.startPrank(owner); + vm.expectRevert("Above _MAX_SWAP_FEE_PERCENTAGE"); + upliftOnlyRouter.setHookSwapFeePercentage(boundFeeAmount); + vm.stopPrank(); + } + + function testFuzzUpliftOnlyAdmin_Succeeds_WithPositiveUplift( + uint256 feeTakeRaw, + uint256 priceScaleRaw, + uint256 minBptRaw + ) public { + // --- Fuzz bounds chosen to avoid exact-join round-up beating maxAmountsIn --- + // Fee take <= 10% + uint256 feeTake = bound(feeTakeRaw, 1e10, 10e16); // [0, 0.10e18] + // Mild uplift 1.02x–1.10x + uint256 priceScale = bound(priceScaleRaw, 102e16, 110e16); // [1.02e18, 1.10e18] + // Large, even BPT for initial join -> deep pool buffers + uint256 minBptOut = bound(minBptRaw, 6e21, 1e22); + minBptOut -= (minBptOut % 2); + + // Set fee take + vm.prank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMUpliftFeeTake(feeTake); + vm.stopPrank(); + + // Bob adds liquidity with conservative minBptOut + uint256[] memory maxIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, maxIn, minBptOut, false, bytes("")); + vm.stopPrank(); + + // Positive uplift (5 entries as in other tests) + int256[] memory prices = new int256[](5); + prices[0] = 0; + prices[1] = int256(priceScale); + prices[2] = int256(priceScale * 2); + prices[3] = int256(priceScale * 3); + prices[4] = int256(priceScale * 4); + updateWeightRunner.setMockPrices(pool, prices); + + // Bob removes ALL router BPT (even). Zero mins to avoid extra constraints. + uint256 routerBpt = IERC20(pool).balanceOf(address(upliftOnlyRouter)); + assertGt(routerBpt, 0, "router should hold BPT from Bob's join"); + uint256 bptIn = routerBpt - (routerBpt % 2); + if (bptIn == 0) bptIn = routerBpt; + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + + uint256[] memory minOutZero = [uint256(0), uint256(0)].toMemoryArray(); + vm.prank(bob); + upliftOnlyRouter.removeLiquidityProportional(bptIn, minOutZero, false, pool); + vm.stopPrank(); + + // Admin should have received some BPT; redeem all with zero mins + address admin = updateWeightRunner.getQuantAMMAdmin(); + uint256 adminBpt = IERC20(pool).balanceOf(admin); + assertEq(adminBpt, 0, "admin should not own BPT as fees are not transferred in underlying tokens"); + // Admin should have received proportional uplift fees in the underlying + uint256 adminDai = dai.balanceOf(admin); + uint256 adminUsdc = usdc.balanceOf(admin); + + assertGt(adminDai, 0, "admin DAI uplift fee not received"); + assertGt(adminUsdc, 0, "admin USDC uplift fee not received"); + + // For proportional removal, fees across tokens should be proportional + assertApproxEqAbs(adminDai, adminUsdc, 1, "admin underlying fees not proportional"); + + // Sanity: admin fee per-token must not exceed gross per-token amount + uint256 perTokenGross = bptIn / 2; + assertLe(adminDai, perTokenGross, "admin DAI fee too large"); + assertLe(adminUsdc, perTokenGross, "admin USDC fee too large"); + vm.prank(admin); + IERC20(pool).approve(address(upliftOnlyRouter), type(uint256).max); + vm.stopPrank(); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + vm.prank(admin); + upliftOnlyRouter.removeLiquidityProportional(adminBpt, minOutZero, false, pool); + vm.stopPrank(); + + assertEq(IERC20(pool).balanceOf(admin), 0, "admin BPT fully withdrawn"); + } + + function testFuzzUpliftOnlyAdminPath_LeavesNoRouterBPT(uint256 feeTakeRaw, uint256 priceScaleRaw) public { + // --- 1) Fuzzed exactness params --- + uint256 feeTake = bound(feeTakeRaw, 0, 9e17); + uint256 priceScale = bound(priceScaleRaw, 11e17, 10e18); + uint256 minBptOut = 6e21; // fixed even target + minBptOut -= (minBptOut % 2); + + vm.prank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMUpliftFeeTake(feeTake); + vm.stopPrank(); + + // --- 2) Add liquidity (even BPT) --- + uint256[] memory maxIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, maxIn, minBptOut, false, bytes("")); + vm.stopPrank(); + + // --- 3) Positive uplift --- + int256[] memory prices = new int256[](tokens.length); + prices[1] = int256(priceScale); + prices[2] = int256(priceScale * 2); + prices[3] = int256(priceScale * 3); + prices[4] = int256(priceScale * 4); + updateWeightRunner.setMockPrices(pool, prices); + + // --- 4) Remove (almost) all router BPT in an even amount; zero mins --- + uint256 routerBpt = IERC20(pool).balanceOf(address(upliftOnlyRouter)); + assertGt(routerBpt, 0, "router should hold BPT"); + + uint256 bptIn = routerBpt - (routerBpt % 2); + if (bptIn == 0) bptIn = routerBpt; + + uint256[] memory minOutZero = [uint256(0), uint256(0)].toMemoryArray(); + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + + vm.prank(bob); + upliftOnlyRouter.removeLiquidityProportional(bptIn, minOutZero, false, pool); + vm.stopPrank(); + + uint256 leftover = IERC20(pool).balanceOf(address(upliftOnlyRouter)); + assertLe(leftover, 1, "router should not retain meaningful BPT"); + } + + function testFuzzUpliftOnlyAdminWithdraw_NoBptBalance(uint256 feeTakeRaw) public { + // --- 1) Any fee take is fine; focus is router guard for non-owners --- + uint256 feeTake = bound(feeTakeRaw, 0, 9e17); + vm.prank(address(vaultAdmin)); + updateWeightRunner.setQuantAMMUpliftFeeTake(feeTake); + vm.stopPrank(); + + // --- 2) Bob add & remove (even BPT; zero mins) to leave no router-owned position for admin --- + uint256 minBptOut = 2e21; + minBptOut -= (minBptOut % 2); + uint256[] memory maxIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray(); + + vm.prank(bob); + upliftOnlyRouter.addLiquidityProportional(pool, maxIn, minBptOut, false, bytes("")); + vm.stopPrank(); + + uint256[] memory minOutZero = [uint256(0), uint256(0)].toMemoryArray(); + uint256 routerBpt = IERC20(pool).balanceOf(address(upliftOnlyRouter)); + uint256 bptIn = routerBpt - (routerBpt % 2); + if (bptIn == 0) bptIn = routerBpt; + vm.warp(block.timestamp + 1 days); // ensure time has passed for fee calc + + vm.prank(bob); + upliftOnlyRouter.removeLiquidityProportional(bptIn, minOutZero, false, pool); + vm.stopPrank(); + + // --- 3) Admin (no recorded user position) attempts removal -> router must revert via its non-owner guard + address admin = updateWeightRunner.getQuantAMMAdmin(); + vm.prank(admin); + vm.expectRevert(); + upliftOnlyRouter.removeLiquidityProportional(5e17, minOutZero, false, pool); + vm.stopPrank(); + } +} diff --git a/pkg/pool-hooks/test/foundry/utils/MockChainlinkOracles.sol b/pkg/pool-hooks/test/foundry/utils/MockChainlinkOracles.sol new file mode 100644 index 00000000..54907809 --- /dev/null +++ b/pkg/pool-hooks/test/foundry/utils/MockChainlinkOracles.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import "@balancer-labs/v3-interfaces/contracts/pool-quantamm/OracleWrapper.sol"; + +contract MockChainlinkOracle is OracleWrapper { + int216 private fixedReply; + uint private immutable delay; + uint40 public oracleTimestamp; + bool throwOnUpdate; + + constructor(int216 _fixedReply, uint _delay) { + fixedReply = _fixedReply; + delay = _delay; + oracleTimestamp = uint40(block.timestamp); + throwOnUpdate = false; + } + + function setThrowOnUpdate(bool _throwOnUpdate) public { + throwOnUpdate = _throwOnUpdate; + } + + function updateData(int216 _fixedReply, uint40 _timestamp) public { + fixedReply = _fixedReply; + oracleTimestamp = _timestamp; + } + + function _getData() internal view override returns (int216 data, uint40 timestamp) { + if (throwOnUpdate) { + revert("MockChainlinkOracle: throwOnUpdate"); + } + data = fixedReply; + timestamp = uint40(block.timestamp - delay); + } +} diff --git a/pkg/pool-quantamm/contracts/UpdateWeightRunner.sol b/pkg/pool-quantamm/contracts/UpdateWeightRunner.sol index 7124865d..a5bcc857 100644 --- a/pkg/pool-quantamm/contracts/UpdateWeightRunner.sol +++ b/pkg/pool-quantamm/contracts/UpdateWeightRunner.sol @@ -139,6 +139,9 @@ contract UpdateWeightRunner is IUpdateWeightRunner { /// @notice The % of the total swap fee that is allocated to the protocol for running costs. uint256 public quantAMMSwapFeeTake = 0.5e18; + /// @notice The % of the total swap fee that is allocated to the protocol for running costs. + uint256 public quantAMMUpliftFeeTake = 0.5e18; + function setQuantAMMSwapFeeTake(uint256 _quantAMMSwapFeeTake) external override { require(msg.sender == quantammAdmin, "ONLYADMIN"); require(_quantAMMSwapFeeTake <= 1e18, "Swap fee must be less than 100%"); @@ -157,15 +160,15 @@ contract UpdateWeightRunner is IUpdateWeightRunner { function setQuantAMMUpliftFeeTake(uint256 _quantAMMUpliftFeeTake) external { require(msg.sender == quantammAdmin, "ONLYADMIN"); require(_quantAMMUpliftFeeTake <= 1e18, "Uplift fee must be less than 100%"); - uint256 oldSwapFee = quantAMMSwapFeeTake; - quantAMMSwapFeeTake = _quantAMMUpliftFeeTake; + uint256 oldUplFee = quantAMMUpliftFeeTake; + quantAMMUpliftFeeTake = _quantAMMUpliftFeeTake; - emit UpliftFeeTakeSet(oldSwapFee, _quantAMMUpliftFeeTake); + emit UpliftFeeTakeSet(oldUplFee, _quantAMMUpliftFeeTake); } /// @notice Get the quantAMM uplift fee % amount allocated to the protocol for running costs function getQuantAMMUpliftFeeTake() external view returns (uint256) { - return quantAMMSwapFeeTake; + return quantAMMUpliftFeeTake; } function getQuantAMMAdmin() external view override returns (address) {