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..43ae2de6 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"; @@ -30,6 +31,8 @@ import { MinimalRouter } from "../MinimalRouter.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { IVaultExplorer } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExplorer.sol"; +import { LPOracleBase } from "@balancer-labs/v3-standalone-utils/contracts/LPOracleBase.sol"; + import { LPNFT } from "./LPNFT.sol"; struct PoolCreationSettings { @@ -88,10 +91,13 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { // NFT unique identifier. uint256 private _nextTokenId; - address private immutable _updateWeightRunner; + address private _updateWeightRunner; + + LPOracleBase public _poolLPOracle; - uint64 private constant _MIN_SWAP_FEE_PERCENTAGE = 0.001e16; // 0.001% - uint64 private constant _MAX_SWAP_FEE_PERCENTAGE = 10e16; // 10% + uint256 private constant _MIN_SWAP_FEE_PERCENTAGE = 0.001e16; // 0.001% + uint256 private constant _MAX_SWAP_FEE_PERCENTAGE = 10e16; // 10% + uint256 public immutable _MAX_UPLIFT_FEE_PERCENTAGE = 10e16; // 10% /** * @notice A new `UpliftOnlyExampleRegistered` contract has been registered successfully for a given pool. @@ -162,6 +168,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 +205,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); @@ -201,7 +221,8 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { address _updateWeightRunnerParam, string memory version, string memory name, - string memory symbol + string memory symbol, + LPOracleBase poolLPOracle ) MinimalRouter(vault, weth, permit2, version) Ownable(msg.sender) { require(bytes(name).length > 0 && bytes(symbol).length > 0, "NAMEREQ"); //Must provide a name / symbol @@ -210,12 +231,32 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { upliftFeeBps = _upliftFeeBps; minWithdrawalFeeBps = _minWithdrawalFeeBps; _updateWeightRunner = _updateWeightRunnerParam; + _poolLPOracle = poolLPOracle; } /*************************************************************************** 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 +264,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, @@ -241,18 +292,15 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { //this requires the pool to be registered with the QuantAMM update weight runner //as well as approved with oracles that provide the prices - uint256 depositValue = getPoolLPTokenValue( - IUpdateWeightRunner(_updateWeightRunner).getData(pool), - pool, - MULDIRECTION.MULDOWN - ); + (, int256 answer, , ,) = _poolLPOracle.latestRoundData(); + require(answer > 0, "answer == 0"); poolsFeeData[pool][msg.sender].push( FeeData({ tokenID: tokenID, amount: exactBptAmountOut, //this rounding favours the LP - lpTokenDepositValue: depositValue, + lpTokenDepositValue: uint256(answer), //known use of timestamp, caveats are known. blockTimestampDeposit: uint40(block.timestamp), upliftFeeBps: upliftFeeBps @@ -262,21 +310,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 +397,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 +409,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 +429,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; @@ -397,18 +463,11 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { return true; } - struct TakeFeeLocalData { - address nftHolder; - address pool; - uint256[] amountsOutRaw; - uint256 currentFee; - IERC20[] tokens; - uint256[] accruedFees; - } - //needed to avoid stack too deep error struct AfterRemoveLiquidityData { address pool; + address userAddress; + address quantammAdminAddress; uint256 bptAmountIn; uint256[] amountsOutRaw; uint256[] minAmountsOut; @@ -416,7 +475,6 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { uint256[] accruedQuantAMMFees; uint256 currentFee; uint256 feeAmount; - int256[] prices; uint256 lpTokenDepositValueNow; int256 lpTokenDepositValueChange; uint256 lpTokenDepositValue; @@ -427,148 +485,186 @@ 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, - RemoveLiquidityKind, - uint256 bptAmountIn, - uint256[] memory, - uint256[] memory amountsOutRaw, - 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; - } + address router, + address pool, + RemoveLiquidityKind, // unchanged param list + uint256 bptAmountIn, + uint256[] memory, // unchanged (unused) + uint256[] memory amountsOutRaw, + uint256[] memory, // unchanged (unused) + bytes memory userData +) public override onlySelfRouter(router) returns (bool, uint256[] memory hookAdjustedAmountsOutRaw) { + // Struct trimmed to only fields actually used later. + AfterRemoveLiquidityData memory localData = AfterRemoveLiquidityData({ + pool: pool, + userAddress: address(bytes20(userData)), + quantammAdminAddress: IUpdateWeightRunner(_updateWeightRunner).getQuantAMMAdmin(), + bptAmountIn: bptAmountIn, + amountsOutRaw: amountsOutRaw, + minAmountsOut: new uint256[](amountsOutRaw.length), + + accruedFees: new uint256[](amountsOutRaw.length), + accruedQuantAMMFees: new uint256[](amountsOutRaw.length), + currentFee: 0, // removed in struct type if you can; else leave defaulted and unused + feeAmount: 0, + lpTokenDepositValueNow: 0, + lpTokenDepositValueChange: 0, + lpTokenDepositValue: 0, + tokens: new IERC20[](amountsOutRaw.length), // remove field from struct type if you can; otherwise leave empty + feeDataArrayLength: 0, // remove field from struct type if you can + amountLeft: 0, + feePercentage: 0, + adminFeePercent: IUpdateWeightRunner(_updateWeightRunner).getQuantAMMUpliftFeeTake() + // minAmountsOut removed from struct type + }); + + if (localData.userAddress == localData.quantammAdminAddress) { + return (true, amountsOutRaw); + } - // 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); + // We only allow removeLiquidity via the Router/Hook itself so that fee is applied correctly. + hookAdjustedAmountsOutRaw = amountsOutRaw; - localData.amountLeft -= feeDataArray[i].amount; + // --- Narrow scope: only keep 'answer' + { + (, int256 answer,,,) = _poolLPOracle.latestRoundData(); + require(answer > 0, "bad price"); + // Current pool USD value, rounded down (same behavior) + localData.lpTokenDepositValueNow = uint256(answer); + } - lpNFT.burn(feeDataArray[i].tokenID); + localData.feeDataArrayLength = poolsFeeData[pool][localData.userAddress].length; + localData.amountLeft = bptAmountIn; - delete feeDataArray[i]; - feeDataArray.pop(); + FeeData[] storage feeDataArray = poolsFeeData[pool][localData.userAddress]; + + // FILO burn: iterate from end, same semantics. + for (uint256 i = localData.feeDataArrayLength; i > 0; --i) { - if (localData.amountLeft == 0) { - break; - } - } else { - feeDataArray[i].amount -= localData.amountLeft; - localData.feeAmount += (feePerLP * localData.amountLeft); + if (feeDataArray[i].blockTimestampDeposit + 60 > block.timestamp) { + revert TooFastWithdrawals(pool, localData.userAddress); + } + + localData.lpTokenDepositValue = feeDataArray[i].lpTokenDepositValue; + localData.lpTokenDepositValueChange = + ((int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) * 1e18) / + int256(localData.lpTokenDepositValueNow); + uint256 feePerLp = 0; + + if (localData.lpTokenDepositValueChange > 0) { + feePerLp = uint256(localData.lpTokenDepositValueChange).mulUp(uint256(feeDataArray[i].upliftFeeBps)); + } + + if (feePerLp < uint256(minWithdrawalFeeBps)) { + feePerLp = uint256(minWithdrawalFeeBps); + } + if (feePerLp > _MAX_UPLIFT_FEE_PERCENTAGE) { + feePerLp = _MAX_UPLIFT_FEE_PERCENTAGE; + } + + if (feeDataArray[i].amount <= localData.amountLeft) { + uint256 withdrawAmount = 0; + withdrawAmount = feeDataArray[i].amount; // short-lived + + localData.feeAmount += withdrawAmount.mulDown(feePerLp); + localData.amountLeft -= withdrawAmount; + + lpNFT.burn(feeDataArray[i].tokenID); + + // Maintain original deletion order: delete then pop + delete feeDataArray[i]; + feeDataArray.pop(); + + if (localData.amountLeft == 0) { break; } + } else { + feeDataArray[i].amount -= localData.amountLeft; + localData.feeAmount += localData.amountLeft.mulDown(feePerLp); + break; } + } - localData.feePercentage = (localData.feeAmount) / bptAmountIn; + localData.feePercentage = localData.feeAmount.divDown(bptAmountIn); - hookAdjustedAmountsOutRaw = localData.amountsOutRaw; + // --- Narrow scope: tokens only live during fee charge + { localData.tokens = _vault.getPoolTokens(localData.pool); - localData.adminFeePercent = IUpdateWeightRunner(_updateWeightRunner).getQuantAMMUpliftFeeTake(); - - // Charge fees proportional to the `amountOut` of each token. + // Charge fees proportional to each token amount out (same math). for (uint256 i = 0; i < localData.amountsOutRaw.length; i++) { - uint256 exitFee = localData.amountsOutRaw[i].mulDown(localData.feePercentage); + uint256 exitFee = localData.amountsOutRaw[i].mulUp(localData.feePercentage); if (localData.adminFeePercent > 0) { - localData.accruedQuantAMMFees[i] = exitFee.mulDown(localData.adminFeePercent); + localData.accruedQuantAMMFees[i] = exitFee.mulUp(localData.adminFeePercent); } 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.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("") - }) - ); + // Same safety check + if (localData.accruedFees[i] + localData.accruedQuantAMMFees[i] > localData.amountsOutRaw[i]) { + revert("Accrued fees exceed amounts out"); + // If you have a custom error: + // revert AccruedFeesExceedAmount(); + } + + if (localData.accruedQuantAMMFees[i] > 0) { + _vault.sendTo(localData.tokens[i], localData.quantammAdminAddress, localData.accruedQuantAMMFees[i]); + } + emit ExitFeeCharged( - userAddress, + localData.userAddress, localData.pool, - IERC20(localData.pool), - localData.feeAmount.mulDown(localData.adminFeePercent) / 1e18 + 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; } + } - return (true, hookAdjustedAmountsOutRaw); + // Donation path unchanged (admin take of 100% = skip donation). + if (localData.adminFeePercent != 1e18) { + _vault.addLiquidity( + AddLiquidityParams({ + pool: localData.pool, + to: localData.userAddress, // donation (no BPT minted back) + maxAmountsIn: localData.accruedFees, // donate all accrued fees + minBptAmountOut: 0, // must be 0 for donation + kind: AddLiquidityKind.DONATION, + userData: bytes("") // unused + }) + ); } + return (true, hookAdjustedAmountsOutRaw); +} + + /// @param _from the owner to transfer from /// @param _to the owner to transfer to /// @param _tokenID the token ID to transfer @@ -581,7 +677,12 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable { address poolAddress = nftPool[_tokenID]; if (poolAddress == address(0)) { - revert TransferUpdateTokenIDInvaid(_from, _to, _tokenID); + revert TransferUpdateTokenIDInvalid(_from, _to, _tokenID); + } + + //changed to 50 instead of 100 so that a dust transfer attack doesnt block depositors + if (poolsFeeData[poolAddress][_to].length >= 50) { + revert TooManyDeposits(poolAddress, _to); } int256[] memory prices = IUpdateWeightRunner(_updateWeightRunner).getData(poolAddress); @@ -607,8 +708,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/contracts/test/LPOracleBaseMock.sol b/pkg/pool-hooks/contracts/test/LPOracleBaseMock.sol new file mode 100644 index 00000000..7777c2b7 --- /dev/null +++ b/pkg/pool-hooks/contracts/test/LPOracleBaseMock.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import { IWeightedLPOracle } from "@balancer-labs/v3-interfaces/contracts/standalone-utils/IWeightedLPOracle.sol"; +import { ILPOracleBase } from "@balancer-labs/v3-interfaces/contracts/standalone-utils/ILPOracleBase.sol"; +import { IWeightedPool } from "@balancer-labs/v3-interfaces/contracts/pool-weighted/IWeightedPool.sol"; +import { Rounding } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; +import { IBasePool } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePool.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; + +import { LPOracleBase } from "@balancer-labs/v3-standalone-utils/contracts/LPOracleBase.sol"; + +contract MockLPOracle is IWeightedLPOracle, LPOracleBase { + using FixedPoint for uint256; + using SafeCast for *; + + uint256 internal immutable _weight0; + uint256 internal immutable _weight1; + uint256 internal immutable _weight2; + uint256 internal immutable _weight3; + uint256 internal immutable _weight4; + uint256 internal immutable _weight5; + uint256 internal immutable _weight6; + uint256 internal immutable _weight7; + + constructor( + IVault vault_, + IWeightedPool pool_, + AggregatorV3Interface[] memory feeds, + uint256 version_ + ) LPOracleBase(vault_, IBasePool(address(pool_)), feeds, version_) { + uint256[] memory weights = pool_.getNormalizedWeights(); + + // prettier-ignore + { + _weight0 = weights[0]; + + _weight1 = weights[1]; + + if (_totalTokens > 2) { + _weight2 = weights[2]; + } + if (_totalTokens > 3) { + _weight3 = weights[3]; + } + if (_totalTokens > 4) { + _weight4 = weights[4]; + } + if (_totalTokens > 5) { + _weight5 = weights[5]; + } + if (_totalTokens > 6) { + _weight6 = weights[6]; + } + if (_totalTokens > 7) { + _weight7 = weights[7]; + } + } + } + + /// @inheritdoc ILPOracleBase + function calculateTVL(int256[] memory prices) public view override returns (uint256 tvl) { + uint256[] memory weights = _getWeights(); + uint256[] memory lastBalancesLiveScaled18 = _vault.getCurrentLiveBalances(address(pool)); + + /********************************************************************************************** + // We know that the normalized value of each token in the pool is equal: + // C = (P1 * B1 / W1) = (P2 * B2 / W2) = ... = (Pn * Bn / Wn) + // + // Where: + // n = number of tokens + // Pi = market price of token i + // Bi = balance of token i + // Wi = normalized weight of token i (sum of all Wi == 1) + // C = common normalized value across tokens + // + // From this, we can express the balance of token i: + // Bi = (C * Wi) / Pi + // + // The total value locked (TVL) is the sum of all token values: + // TVL = Σ (Bi * Pi) + // Substituting Bi: + // TVL = Σ ((C * Wi / Pi) * Pi) = C * Σ(Wi) = C + // C = TVL + // + // So: + // Bi = (TVL * Wi) / Pi + // + // The invariant of the WeightedPool pool is defined as: + // k = Π (Bi^Wi) + // + // Substituting Bi and using the fact that Σ(Wi) = 1: + // k = Π ((TVL * Wi / Pi)^Wi) + // = TVL^Σ(Wi) * Π((Wi / Pi)^Wi) + // = TVL * Π((Wi / Pi)^Wi) + // + // Solving for TVL: + // TVL = k * Π((Pi / Wi)^Wi) + **********************************************************************************************/ + + /********************************************************************************************** + // invariant _____ // + // wi = weight index i | | wi // + // pi = price index i k * | | (pi/wi) ^ = tvl // + // k = invariant // + **********************************************************************************************/ + + uint256 k = pool.computeInvariant(lastBalancesLiveScaled18, Rounding.ROUND_UP); + + tvl = FixedPoint.ONE; + for (uint256 i = 0; i < _totalTokens; i++) { + tvl = tvl.mulDown(prices[i].toUint256().divDown(weights[i]).powDown(weights[i])); + } + + tvl = tvl.mulDown(k); + } + + function getWeights() external view returns (uint256[] memory) { + return _getWeights(); + } + + function _getWeights() internal view returns (uint256[] memory) { + uint256[] memory weights = new uint256[](_totalTokens); + + // prettier-ignore + { + weights[0] = _weight0; + weights[1] = _weight1; + if (_totalTokens > 2) { weights[2] = _weight2; } else { return weights; } + if (_totalTokens > 3) { weights[3] = _weight3; } else { return weights; } + if (_totalTokens > 4) { weights[4] = _weight4; } else { return weights; } + if (_totalTokens > 5) { weights[5] = _weight5; } else { return weights; } + if (_totalTokens > 6) { weights[6] = _weight6; } else { return weights; } + if (_totalTokens > 7) { weights[7] = _weight7; } + } + + return weights; + } +} diff --git a/pkg/pool-hooks/foundry.toml b/pkg/pool-hooks/foundry.toml index c56461b8..88520745 100755 --- a/pkg/pool-hooks/foundry.toml +++ b/pkg/pool-hooks/foundry.toml @@ -21,11 +21,12 @@ remappings = [ 'permit2/=../../node_modules/permit2/', '@balancer-labs/=../../node_modules/@balancer-labs/', '@prb/=../../node_modules/@prb', + '@chainlink=../../node_modules/@chainlink/', 'forge-gas-snapshot/=../../node_modules/forge-gas-snapshot/src/' ] 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..52459e46 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"; @@ -39,6 +43,7 @@ import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { UpliftOnlyExample } from "../../contracts/hooks-quantamm/UpliftOnlyExample.sol"; import { LPNFT } from "../../contracts/hooks-quantamm/LPNFT.sol"; +import { WeightedLPOracleMock } from "@balancer-labs/v3-standalone-utils/contracts/test/WeightedLPOracleMock.sol"; contract UpliftOnlyExampleTest is BaseVaultTest { using CastingHelpers for address[]; @@ -66,7 +71,8 @@ contract UpliftOnlyExampleTest is BaseVaultTest { UpliftOnlyExample internal upliftOnlyRouter; - // Overrides `setUp` to include a deployment for UpliftOnlyExample. + WeightedLPOracleMock internal lpOracle; + function setUp() public virtual override { BaseTest.setUp(); (address ownerLocal, address addr1Local, address addr2Local) = (vm.addr(1), vm.addr(2), vm.addr(3)); @@ -98,26 +104,30 @@ contract UpliftOnlyExampleTest is BaseVaultTest { vm.stopPrank(); + + // 1) Deploy the LP oracle mock the same way as in WeightedLPOracle.t.sol + lpOracle = new WeightedLPOracleMock(); + + vm.startPrank(owner); upliftOnlyRouter = new UpliftOnlyExample( IVault(address(vault)), weth, permit2, - 200, - 5, + 200e14, + 5e14, address(updateWeightRunner), "Uplift LiquidityPosition v1", "Uplift LiquidityPosition v1", - "Uplift LiquidityPosition v1" + "Uplift LiquidityPosition v1", + lpOracle ); 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); @@ -171,11 +181,8 @@ contract UpliftOnlyExampleTest is BaseVaultTest { 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); + + lpOracle.setPrice(int256(1e18)); PoolRoleAccounts memory roleAccounts; roleAccounts.poolCreator = lp; @@ -210,7 +217,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 +227,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 +242,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 +259,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 +272,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 +283,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 +326,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 - // Bob gets original liquidity with no fee applied because of full decay. + // 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 + + // === 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 +491,138 @@ contract UpliftOnlyExampleTest is BaseVaultTest { 500000000000000000, "should match sum(amount * price)" ); - assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee"); - - int256[] memory prices = new int256[](tokens.length); - for (uint256 i = 0; i < tokens.length; ++i) { - prices[i] = int256(i) / 2; - } - updateWeightRunner.setMockPrices(pool, prices); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200e14, "fee"); - uint256 nftTokenId = 0; - uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + // 2) Push prices DOWN so there is a negative uplift. + // With negative uplift, the contract applies minimum withdrawal fee (minWithdrawalFeeBps). + lpOracle.setPrice(int256(0.5e18)); - 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 - // Bob gets original liquidity with no fee applied because of full decay. + // 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 + + // 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 +637,124 @@ 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); - for (uint256 i = 0; i < tokens.length; ++i) { - prices[i] = int256(i) * 2e18; - } - updateWeightRunner.setMockPrices(pool, prices); + // Push prices up so there is positive uplift (value doubles from 0.5 -> 1.0). + lpOracle.setPrice(int256(2e18)); - uint256 nftTokenId = 0; - uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); - - 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; - /* - 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. - */ + // Uplift ratio = (now - deposit) / now + v.upliftRatio = ((v.valueNow - v.valueAtDeposit) * 1e18) / v.valueNow; - uint256 amountOut = (bptAmount / 2).mulDown((1e18 - feeAmountAmountPercent)); + // Effective fee% + v.feePercentage = v.upliftRatio.mulDown(uint256(upliftOnlyRouter.upliftFeeBps())); - // Bob gets original liquidity with no fee applied because of full decay. + // Each token pays out bptAmount/2 on a symmetric pool. + v.amountOutRawPerToken = bptAmount / 2; + + // Total per-token exit fee (before splitting) + v.hookFeePerToken = v.amountOutRawPerToken.mulDown(v.feePercentage); + + // 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 +765,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 +784,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 +801,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 +824,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 +928,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 +940,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 +962,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 +1010,447 @@ 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) + lpOracle.setPrice(int256(0.5e18)); - BaseVaultTest.Balances memory balancesBefore = getBalances(updateWeightRunner.getQuantAMMAdmin()); + // 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); - for (uint256 i = 0; i < tokens.length; ++i) { - prices[i] = int256(i) / 2; - } - updateWeightRunner.setMockPrices(pool, prices); + // double prices (uplift 100%) + lpOracle.setPrice(int256(2e18)); - 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); - // Bob gets original liquidity with no fee applied because of full decay. + // 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; + + // 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%) + lpOracle.setPrice(int256(2e18)); + + // 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]) + lpOracle.setPrice(int256(2e18)); + + // -------------------------------------------- + // 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 +1460,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"); - - int256[] memory prices = new int256[](tokens.length); - for (uint256 i = 0; i < tokens.length; ++i) { - prices[i] = int256(i) * 2e18; - } - updateWeightRunner.setMockPrices(pool, prices); + assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200e14, "fee"); - uint256 nftTokenId = 0; - uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + // ----- Baselines before withdrawal ----- + v.minAmountsOut = [uint256(0), uint256(0)].toMemoryArray(); + v.qaAdmin = updateWeightRunner.getQuantAMMAdmin(); - 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..8384d7c4 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) { @@ -603,6 +606,13 @@ contract UpdateWeightRunner is IUpdateWeightRunner { //L01 possible if multiplier is 0 if (currentLastInterpolationPossible < int256(type(int40).max) - int256(int40(uint40(block.timestamp)))) { + if(currentLastInterpolationPossible < 0) { + //an ultimate final backstop, so we need to set the last interpolation time to the current blocktime + //current weights are handled by the same multiplier process so in theory not possible but if a manual intervention was not + //added correctly this prevents any big weight jump + currentLastInterpolationPossible = 0; + } + //next expected update + time beyond that lastTimestampThatInterpolationWorks = uint40( int40(currentLastInterpolationPossible + int40(uint40(block.timestamp))) diff --git a/pkg/pool-quantamm/contracts/deployment/admin-scripts/add_liquidity.sol b/pkg/pool-quantamm/contracts/deployment/admin-scripts/add_liquidity.sol new file mode 100644 index 00000000..c987a3cf --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/admin-scripts/add_liquidity.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + function run() external { + // For dry runs, we don't need a private key + vm.startBroadcast(); + address pool = 0xd4Ed17bBF48Af09B87fD7d8C60970f5Da79D4852; + address permit2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + address router = 0xAE563E3f8219521950555F5962419C8919758Ea2; + // Approve permit2 contract on token + IERC20(pool).approve(permit2, type(uint256).max); + // Approve router on Permit2 + IPermit2(permit2).approve(pool, router, type(uint160).max, type(uint48).max); + + + IERC20[] memory tokenAddresses = new IERC20[](3); + tokenAddresses[0] = IERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599); + tokenAddresses[1] = IERC20(0x45804880De22913dAFE09f4980848ECE6EcbAf78); + tokenAddresses[2] = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + + IERC20(tokenAddresses[0]).approve(address(permit2), type(uint256).max); + IERC20(tokenAddresses[1]).approve(address(permit2), type(uint256).max); + IERC20(tokenAddresses[2]).approve(address(permit2), type(uint256).max); + + // Approve token 0 using Permit2 + IPermit2(permit2).approve( + address(tokenAddresses[0]), + 0xAE563E3f8219521950555F5962419C8919758Ea2, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + // Approve token 1 using Permit2 + IPermit2(permit2).approve( + address(tokenAddresses[1]), + 0xAE563E3f8219521950555F5962419C8919758Ea2, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + // Approve token 1 using Permit2 + IPermit2(permit2).approve( + address(tokenAddresses[2]), + 0xAE563E3f8219521950555F5962419C8919758Ea2, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + + uint256[] memory amountIn = new uint256[](3); + amountIn[0] = uint256(0); + amountIn[1] = uint256(0); + amountIn[2] = uint256(0); + bytes memory userData = ""; + + IRouter(router).addLiquidityUnbalanced( + pool, + amountIn, + 0, + false, + userData + ); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/admin-scripts/approve_oracle.sol b/pkg/pool-quantamm/contracts/deployment/admin-scripts/approve_oracle.sol new file mode 100644 index 00000000..7227fdd7 --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/admin-scripts/approve_oracle.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +interface IDelayModifier { + function executeNextTx(address to, uint256 value, bytes calldata data, uint8 operation) external; +} + +contract Deploy is Script { + function run() external { + uint256 deployerPrivateKey; + + // Only load the private key if broadcasting (i.e., not dry run) + if (block.chainid != 11155111) { + // Replace 11155111 with the chain ID you're working with (e.g., Sepolia) + deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + } else { + // For dry runs, we don't need a private key + vm.startBroadcast(); + } + + // replace with your deployed addresses & payload + IDelayModifier delay = IDelayModifier(0x4F824dDe06314a7Aa1091902d17B82c4b519F424); + address target = 0xeE20C7956bd715052DF13DB9BD77984Eab85F0C4; + uint256 value = 0; + bytes memory data = hex"df5dd1a50000000000000000000000006fe415f986b12da4381d7082ca0223a0a49771a9"; + uint8 operation = 0; + + delay.executeNextTx(target, value, data, operation); + + //BTC + //UpdateWeightRunner(0x34932B2670BC4fb110fBe7772f0fC9905269705E).addOracle(OracleWrapper(0x6fE415F986b12Da4381d7082CA0223a0a49771A9)); + // + ////ETH + //UpdateWeightRunner(0x34932B2670BC4fb110fBe7772f0fC9905269705E).addOracle(OracleWrapper(0x70BE6803cD94EEecA55603C25a550d78D619B037)); + // + ////PAXG + //UpdateWeightRunner(0x34932B2670BC4fb110fBe7772f0fC9905269705E).addOracle(OracleWrapper(0x2E24826974Cd23bb851dBdbFD838521c61A530b3)); + // + ////USDC + //UpdateWeightRunner(0x34932B2670BC4fb110fBe7772f0fC9905269705E).addOracle(OracleWrapper(0x47eD785C84376F49610b90cea0A88dAe447B7881)); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/admin-scripts/approve_pool_for_use.sol b/pkg/pool-quantamm/contracts/deployment/admin-scripts/approve_pool_for_use.sol new file mode 100644 index 00000000..432ad0b8 --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/admin-scripts/approve_pool_for_use.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + function run() external { + uint256 deployerPrivateKey; + + // Only load the private key if broadcasting (i.e., not dry run) + if (block.chainid != 11155111) { + // Replace 11155111 with the chain ID you're working with (e.g., Sepolia) + deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + } else { + // For dry runs, we don't need a private key + vm.startBroadcast(); + } + + UpdateWeightRunner(0x26570ad4CC61eA3E944B1c4660416E45796D44b3).setApprovedActionsForPool( + 0x6663545aF63bC3268785Cf859f0608506759EBe8, + uint256(19) + ); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/admin-scripts/mint_weth.sol b/pkg/pool-quantamm/contracts/deployment/admin-scripts/mint_weth.sol new file mode 100644 index 00000000..13c5272c --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/admin-scripts/mint_weth.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +interface IWETH { + function deposit() external payable; +} + +contract Deploy is Script { + function run() external { + uint256 deployerPrivateKey; + + // Only load the private key if broadcasting (i.e., not dry run) + if (block.chainid != 11155111) { + // Replace 11155111 with the chain ID you're working with (e.g., Sepolia) + deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + } else { + // For dry runs, we don't need a private key + vm.startBroadcast(); + } + + IWETH(0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9).deposit{ value: 5000000000000000000 }(); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/admin-scripts/remove_liquidity.sol b/pkg/pool-quantamm/contracts/deployment/admin-scripts/remove_liquidity.sol new file mode 100644 index 00000000..ba9b43fc --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/admin-scripts/remove_liquidity.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + function run() external { + // For dry runs, we don't need a private key + vm.startBroadcast(); + + IERC20(0x314fDFAf8AD9b50fF105993C722a1826019Cf21D).approve(0xAE563E3f8219521950555F5962419C8919758Ea2, type(uint256).max); + + uint256[] memory minAmountsOut = new uint256[](3); + minAmountsOut[0] = uint256(0); + minAmountsOut[1] = uint256(0); + minAmountsOut[2] = uint256(0); + + bytes memory userData = ""; + + IRouter(0xAE563E3f8219521950555F5962419C8919758Ea2).removeLiquidityProportional( + 0x314fDFAf8AD9b50fF105993C722a1826019Cf21D, + uint256(0.2646781979e18), + minAmountsOut, + false, + userData + ); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/diagnostics/get_data.sol b/pkg/pool-quantamm/contracts/deployment/diagnostics/get_data.sol new file mode 100644 index 00000000..aaf23be1 --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/diagnostics/get_data.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "@openzeppelin//contracts/utils/Strings.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IQuantAMMWeightedPool } from "@balancer-labs/v3-interfaces/contracts/pool-quantamm/IQuantAMMWeightedPool.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + using Strings for uint256; + using Strings for uint64; + using Strings for uint40; + + function run() external { + // For dry runs, we don't need a private key + vm.startBroadcast(); + + (int256 data, uint40 timestamp) = OracleWrapper(0xaAFB604Dc5c7D178e767eD576cA9aa6D48B350C2).getData(); + console.log("Data"); + if (data < 0) { + console.log(string.concat("-", uint256(-data).toString())); + } else { + console.log(uint256(data).toString()); + } + console.log("Timestamp"); + console.log(timestamp.toString()); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/diagnostics/pool_check.sol b/pkg/pool-quantamm/contracts/deployment/diagnostics/pool_check.sol new file mode 100644 index 00000000..33d6e65b --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/diagnostics/pool_check.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "@openzeppelin//contracts/utils/Strings.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IQuantAMMWeightedPool } from "@balancer-labs/v3-interfaces/contracts/pool-quantamm/IQuantAMMWeightedPool.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + using Strings for uint256; + using Strings for uint64; + using Strings for uint40; + + function run() external { + // For dry runs, we don't need a private key + vm.startBroadcast(); + + address pool = 0xd4Ed17bBF48Af09B87fD7d8C60970f5Da79D4852; + address rule = 0x62B9eC6A5BBEBe4F5C5f46C8A8880df857004295; + address updateWeightRunnerAddress = 0x21Ae9576a393413D6d91dFE2543dCb548Dbb8748; + + IQuantAMMWeightedPool.QuantAMMWeightedPoolDynamicData memory weights = QuantAMMWeightedPool(pool) + .getQuantAMMWeightedPoolDynamicData(); + + console.log("Total Supply"); + console.logUint(weights.totalSupply); + console.log(weights.totalSupply.toString()); + + console.log("Is Pool Initialized"); + console.log(weights.isPoolInitialized); + + console.log("Is Pool Paused"); + console.log(weights.isPoolPaused); + + console.log("Is Pool In Recovery Mode"); + console.log(weights.isPoolInRecoveryMode); + + IQuantAMMWeightedPool.QuantAMMWeightedPoolImmutableData memory immutableData = QuantAMMWeightedPool(pool) + .getQuantAMMWeightedPoolImmutableData(); + + console.log("Oracle Staleness Threshold"); + console.logUint(immutableData.oracleStalenessThreshold); + console.log(immutableData.oracleStalenessThreshold.toString()); + + console.log("Pool Registry"); + console.logUint(immutableData.poolRegistry); + console.log(immutableData.poolRegistry.toString()); + + console.log("Epsilon Max"); + console.logUint(immutableData.epsilonMax); + console.log(immutableData.epsilonMax.toString()); + + console.log("Absolute Weight Guard Rail"); + console.logUint(immutableData.absoluteWeightGuardRail); + console.log(immutableData.absoluteWeightGuardRail.toString()); + + console.log("Update Interval"); + console.logUint(immutableData.updateInterval); + console.log(immutableData.updateInterval.toString()); + + console.log("Max Trade Size Ratio"); + console.logUint(immutableData.maxTradeSizeRatio); + console.log(immutableData.maxTradeSizeRatio.toString()); + + console.log("Tokens"); + for (uint256 i = 0; i < immutableData.tokens.length; i++) { + console.log(address(immutableData.tokens[i])); + } + + console.log("Lambda"); + for (uint256 i = 0; i < immutableData.lambda.length; i++) { + console.logUint(immutableData.lambda[i]); + console.log(immutableData.lambda[i].toString()); + } + + console.log("Rule Parameters"); + for (uint256 i = 0; i < immutableData.ruleParameters.length; i++) { + for (uint256 j = 0; j < immutableData.ruleParameters[i].length; j++) { + console.logInt(immutableData.ruleParameters[i][j]); + if (immutableData.ruleParameters[i][j] < 0) { + console.log(string.concat("-", uint256(-immutableData.ruleParameters[i][j]).toString())); + } else { + console.log(uint256(immutableData.ruleParameters[i][j]).toString()); + } + } + } + console.log("Balances Live Scaled 18"); + for (uint256 i = 0; i < weights.balancesLiveScaled18.length; i++) { + console.logInt(int256(weights.balancesLiveScaled18[i])); + console.log(weights.balancesLiveScaled18[i].toString()); + } + + console.log("weights and multipliers"); + for (uint256 i = 0; i < weights.firstFourWeightsAndMultipliers.length; i++) { + console.logInt(int256(weights.firstFourWeightsAndMultipliers[i])); + if (weights.firstFourWeightsAndMultipliers[i] < 0) { + console.log(string.concat("-", uint256(-weights.firstFourWeightsAndMultipliers[i]).toString())); + } else { + console.log(uint256(weights.firstFourWeightsAndMultipliers[i]).toString()); + } + } + + uint256[] memory weightsAndMultipliers = QuantAMMWeightedPool(pool).getNormalizedWeights(); + + console.log("normalized weights"); + for (uint256 i = 0; i < weightsAndMultipliers.length; i++) { + console.logInt(int256(weightsAndMultipliers[i])); + console.log(weightsAndMultipliers[i].toString()); + } + + console.log("intermediate state"); + + int256[] memory intermediateState = PowerChannelUpdateRule(rule).getIntermediateGradientState(pool, 3); + for (uint256 i = 0; i < intermediateState.length; i++) { + console.logInt(intermediateState[i]); + if (intermediateState[i] < 0) { + console.log(string.concat("-", uint256(-intermediateState[i]).toString())); + } else { + console.log(uint256(intermediateState[i]).toString()); + } + } + + int256[] memory movingAverages = PowerChannelUpdateRule(rule) + .getMovingAverages(pool, 3); + console.log("movingAverages"); + for (uint256 i = 0; i < movingAverages.length; i++) { + console.logInt(int256(movingAverages[i])); + if (movingAverages[i] < 0) { + console.log(string.concat("-", uint256(-movingAverages[i]).toString())); + } else { + console.log(uint256(movingAverages[i]).toString()); + } + } + + console.log("last update time"); + console.logUint(uint256(weights.lastInteropTime)); + console.log(weights.lastInteropTime.toString()); + console.logUint(uint256(weights.lastUpdateTime)); + console.log(weights.lastUpdateTime.toString()); + + address[] memory oracles = UpdateWeightRunner(updateWeightRunnerAddress).getOptimisedPoolOracle(pool); + + console.log("poolOracles"); + + for (uint256 i = 0; i < oracles.length; i++) { + console.log(oracles[i]); + } + + console.log("approved permissions"); + uint256 registry = UpdateWeightRunner(updateWeightRunnerAddress).getPoolApprovedActions(pool); + console.logUint(registry); + console.log(registry.toString()); + + console.log("getPoolRuleSettings"); + IUpdateWeightRunner.PoolRuleSettings memory ruleSettings = UpdateWeightRunner(updateWeightRunnerAddress) + .getPoolRuleSettings(pool); + console.log("Lambda"); + for (uint256 i = 0; i < ruleSettings.lambda.length; i++) { + console.logUint(ruleSettings.lambda[i]); + console.log(ruleSettings.lambda[i].toString()); + } + + console.log("Timing Settings"); + console.logUint(ruleSettings.timingSettings.updateInterval); + console.log(ruleSettings.timingSettings.updateInterval.toString()); + console.log("Last Pool Update Run"); + console.logUint(ruleSettings.timingSettings.lastPoolUpdateRun); + console.log(ruleSettings.timingSettings.lastPoolUpdateRun.toString()); + + console.log("Epsilon Max"); + console.logUint(ruleSettings.epsilonMax); + console.log(ruleSettings.epsilonMax.toString()); + + console.log("Absolute Weight Guard Rail"); + console.logUint(ruleSettings.absoluteWeightGuardRail); + console.log(ruleSettings.absoluteWeightGuardRail.toString()); + + console.log("Rule Parameters"); + for (uint256 i = 0; i < ruleSettings.ruleParameters.length; i++) { + for (uint256 j = 0; j < ruleSettings.ruleParameters[i].length; j++) { + console.logInt(ruleSettings.ruleParameters[i][j]); + if (ruleSettings.ruleParameters[i][j] < 0) { + console.log(string.concat("-", uint256(-ruleSettings.ruleParameters[i][j]).toString())); + } else { + console.log(uint256(ruleSettings.ruleParameters[i][j]).toString()); + } + } + } + + console.log("Pool Manager"); + console.log(ruleSettings.poolManager); + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/disaster-recovery/manual_weight_update.sol b/pkg/pool-quantamm/contracts/deployment/disaster-recovery/manual_weight_update.sol new file mode 100644 index 00000000..090ca75c --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/disaster-recovery/manual_weight_update.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import {Script} from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + + +contract Deploy is Script { + function run() external { + uint256 deployerPrivateKey; + + address pool = 0x6663545aF63bC3268785Cf859f0608506759EBe8; + + // Only load the private key if broadcasting (i.e., not dry run) + if (block.chainid != 11155111) { // Replace 11155111 with the chain ID you're working with (e.g., Sepolia) + deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + } else { + // For dry runs, we don't need a private key + vm.startBroadcast(); + } + + int256[] memory weightsAndMultpliers = new int256[](8); + weightsAndMultpliers[0] = 0.4901884e18; // weight 0.1e18 + weightsAndMultpliers[1] = 0.4898068e18; // weight 0.3e18 + weightsAndMultpliers[2] = 0.0100024e18; // weight 0.5e18 + weightsAndMultpliers[3] = 0.0100024e18; // weight 0.2e18 + weightsAndMultpliers[4] = -3.612855556e13; // multiplier -3.612855556e13 + weightsAndMultpliers[5] = -1.75747037e13; // multiplier -1.75747037e13 + weightsAndMultpliers[6] = 4.537014815e13; // multiplier 4.537014815e13 + weightsAndMultpliers[7] = 1.759237037e13; // multiplier 1.759237037e13 + + uint40 lastInterpolationTimePossible = uint40(block.timestamp) + uint40(10800); // 3 hours + + UpdateWeightRunner(0x26570ad4CC61eA3E944B1c4660416E45796D44b3) + .setWeightsManually(weightsAndMultpliers, pool, lastInterpolationTimePossible, 4); + + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_intermediate_values.sol b/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_intermediate_values.sol new file mode 100644 index 00000000..4af8149b --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_intermediate_values.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + function run() external { + uint256 deployerPrivateKey; + + // Only load the private key if broadcasting (i.e., not dry run) + if (block.chainid != 11155111) { + // Replace 11155111 with the chain ID you're working with (e.g., Sepolia) + deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + } else { + // For dry runs, we don't need a private key + vm.startBroadcast(); + } + + int256[] memory newMovingAverages = new int256[](4); + newMovingAverages[0] = 1e18; + newMovingAverages[1] = 1e18; + newMovingAverages[2] = 1e18; + newMovingAverages[3] = 1e18; + + int256[] memory newParameters = new int256[](4); + newParameters[0] = 1e18; + newParameters[1] = 1e18; + newParameters[2] = 1e18; + newParameters[3] = 1e18; + + UpdateWeightRunner(0x26570ad4CC61eA3E944B1c4660416E45796D44b3).setIntermediateValuesManually( + 0x6663545aF63bC3268785Cf859f0608506759EBe8, + newMovingAverages, + newParameters, + 4 + ); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_pool.sol b/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_pool.sol new file mode 100644 index 00000000..cf8776c5 --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_pool.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + function run() external { + + // For dry runs, we don't need a private key + vm.startBroadcast(); + + IERC20[] memory tokenAddresses = new IERC20[](3); + tokenAddresses[0] = IERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599); + tokenAddresses[1] = IERC20(0x45804880De22913dAFE09f4980848ECE6EcbAf78); + tokenAddresses[2] = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + + IPermit2 permit2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3); // Permit2 contract address + + IERC20(tokenAddresses[0]).approve(address(permit2), type(uint256).max); + IERC20(tokenAddresses[1]).approve(address(permit2), type(uint256).max); + IERC20(tokenAddresses[2]).approve(address(permit2), type(uint256).max); + + // Approve token 0 using Permit2 + permit2.approve( + address(tokenAddresses[0]), + 0xAE563E3f8219521950555F5962419C8919758Ea2, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + // Approve token 1 using Permit2 + permit2.approve( + address(tokenAddresses[1]), + 0xAE563E3f8219521950555F5962419C8919758Ea2, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + // Approve token 1 using Permit2 + permit2.approve( + address(tokenAddresses[2]), + 0xAE563E3f8219521950555F5962419C8919758Ea2, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + uint256[] memory weights = new uint256[](3); + weights[0] = uint256(126506); + weights[1] = uint256(1126277192074030000); + weights[2] = uint256(119423884); + + //IVault(0xbA1333333333a1BA1108E8412f11850A5C319bA9).sendTo(IERC20(0xff34b3d4aee8ddcd6f9afffb6fe49bd371b8a357), msg.sender, uint256(1)); + //IVault(0xbA1333333333a1BA1108E8412f11850A5C319bA9).sendTo(IERC20(0x29f2D40B0605204364af54EC677bD022dA425d03), msg.sender, uint256(1)); + IRouter(0xAE563E3f8219521950555F5962419C8919758Ea2).initialize( + 0xd4Ed17bBF48Af09B87fD7d8C60970f5Da79D4852, + tokenAddresses, + weights, + 0, + false, + bytes("") + ); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_pool_copy.sol b/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_pool_copy.sol new file mode 100644 index 00000000..0ca28565 --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_pool_copy.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + function run() external { + uint256 deployerPrivateKey; + + // Only load the private key if broadcasting (i.e., not dry run) + if (block.chainid != 11155111) { + // Replace 11155111 with the chain ID you're working with (e.g., Sepolia) + deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + } else { + // For dry runs, we don't need a private key + vm.startBroadcast(); + } + + IERC20[] memory tokenAddresses = new IERC20[](4); + tokenAddresses[0] = IERC20(0x29f2D40B0605204364af54EC677bD022dA425d03); + tokenAddresses[1] = IERC20(0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9); + tokenAddresses[2] = IERC20(0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8); + tokenAddresses[3] = IERC20(0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357); + + IPermit2 permit2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3); // Permit2 contract address + + IERC20(tokenAddresses[0]).approve(address(permit2), type(uint256).max); + IERC20(tokenAddresses[1]).approve(address(permit2), type(uint256).max); + IERC20(tokenAddresses[2]).approve(address(permit2), type(uint256).max); + IERC20(tokenAddresses[3]).approve(address(permit2), type(uint256).max); + + // Approve token 0 using Permit2 + permit2.approve( + address(tokenAddresses[0]), + 0x0BF61f706105EA44694f2e92986bD01C39930280, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + // Approve token 1 using Permit2 + permit2.approve( + address(tokenAddresses[1]), + 0x0BF61f706105EA44694f2e92986bD01C39930280, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + // Approve token 1 using Permit2 + permit2.approve( + address(tokenAddresses[2]), + 0x0BF61f706105EA44694f2e92986bD01C39930280, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + // Approve token 1 using Permit2 + permit2.approve( + address(tokenAddresses[3]), + 0x0BF61f706105EA44694f2e92986bD01C39930280, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + uint256[] memory weights = new uint256[](4); + weights[0] = uint256(1000000); + weights[1] = uint256(1000000); + weights[2] = uint256(1000000); + weights[3] = uint256(1000000); + + //IVault(0xbA1333333333a1BA1108E8412f11850A5C319bA9).sendTo(IERC20(0xff34b3d4aee8ddcd6f9afffb6fe49bd371b8a357), msg.sender, uint256(1)); + //IVault(0xbA1333333333a1BA1108E8412f11850A5C319bA9).sendTo(IERC20(0x29f2D40B0605204364af54EC677bD022dA425d03), msg.sender, uint256(1)); + IRouter(0x0BF61f706105EA44694f2e92986bD01C39930280).initialize( + 0x6663545aF63bC3268785Cf859f0608506759EBe8, + tokenAddresses, + weights, + 0, + false, + bytes("") + ); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_rule_runner.sol b/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_rule_runner.sol new file mode 100644 index 00000000..3effefd8 --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/pool-initialisation/initialise_rule_runner.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + function run() external { + uint256 deployerPrivateKey; + + // Only load the private key if broadcasting (i.e., not dry run) + if (block.chainid != 11155111) { + // Replace 11155111 with the chain ID you're working with (e.g., Sepolia) + deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + } else { + // For dry runs, we don't need a private key + vm.startBroadcast(); + } + + UpdateWeightRunner(0x26570ad4CC61eA3E944B1c4660416E45796D44b3).InitialisePoolLastRunTime( + 0x6663545aF63bC3268785Cf859f0608506759EBe8, + uint40(10) + ); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/pool-running/perform_swap.sol b/pkg/pool-quantamm/contracts/deployment/pool-running/perform_swap.sol new file mode 100644 index 00000000..b0e7624f --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/pool-running/perform_swap.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SwapKind, VaultSwapParams } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +contract Deploy is Script { + function run() external { + + // For dry runs, we don't need a private key + vm.startBroadcast(); + address pool = 0xd4Ed17bBF48Af09B87fD7d8C60970f5Da79D4852; + address permit2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + address router = 0xAE563E3f8219521950555F5962419C8919758Ea2; + + ////Approve token 0 using Permit2 + //IPermit2(permit2).approve( + // 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + // router, // The contract that will spend tokens + // uint160(type(uint256).max), // Amount to approve + // uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + //); +// + ////Approve token 0 using Permit2 + //IPermit2(permit2).approve( + // 0x45804880De22913dAFE09f4980848ECE6EcbAf78, + // router, // The contract that will spend tokens + // uint160(type(uint256).max), // Amount to approve + // uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + //); + + + //Approve token 0 using Permit2 + IPermit2(permit2).approve( + 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, + router, // The contract that will spend tokens + uint160(type(uint256).max), // Amount to approve + uint48(block.timestamp + 24 hours) // Expiry: 24 hours from now + ); + + //Approve permit2 contract on token + //IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48).approve(permit2, type(uint256).max); + //IERC20(0x45804880De22913dAFE09f4980848ECE6EcbAf78).approve(permit2, type(uint256).max); + IERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599).approve(permit2, type(uint256).max); + //Approve router on Permit2 + //IPermit2(permit2).approve(pool, router, type(uint160).max, type(uint48).max); + + IRouter(router).swapSingleTokenExactIn( + pool, // pool + IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48), // tokenIn + IERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599), // tokenOut + 9364160, // exactAmountIn + 0, // minAmountOut + 999999999999999999, // deadline + false, // wethIsEth + "" // userData + ); + + vm.stopBroadcast(); + } +} diff --git a/pkg/pool-quantamm/contracts/deployment/pool-running/perform_update.sol b/pkg/pool-quantamm/contracts/deployment/pool-running/perform_update.sol new file mode 100644 index 00000000..47a1e2ef --- /dev/null +++ b/pkg/pool-quantamm/contracts/deployment/pool-running/perform_update.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import "forge-std/console.sol"; // Import the console library for logging +import { Script } from "forge-std/Script.sol"; +import "../../rules/AntimomentumUpdateRule.sol"; +import "../../rules/MomentumUpdateRule.sol"; +import "../../rules/DifferenceMomentumUpdateRule.sol"; +import "../../rules/ChannelFollowingUpdateRule.sol"; +import "../../rules/MinimumVarianceUpdateRule.sol"; +import "../../rules/PowerChannelUpdateRule.sol"; +import "../../UpdateWeightRunner.sol"; +import "../../QuantAMMWeightedPoolFactory.sol"; +import "../../ChainlinkOracle.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract Deploy is Script { + function run() external { + + // For dry runs, we don't need a private key + vm.startBroadcast(); + + address pool = 0x314fDFAf8AD9b50fF105993C722a1826019Cf21D; + address updateWeightRunnerAddress = 0x21Ae9576a393413D6d91dFE2543dCb548Dbb8748; + + UpdateWeightRunner(updateWeightRunnerAddress).performUpdate(pool); + + vm.stopBroadcast(); + } +}