diff --git a/pkg/pool-weighted/contracts/WeightedPool.sol b/pkg/pool-weighted/contracts/WeightedPool.sol index 321e362cb..3af8a7fb2 100644 --- a/pkg/pool-weighted/contracts/WeightedPool.sol +++ b/pkg/pool-weighted/contracts/WeightedPool.sol @@ -52,7 +52,7 @@ contract WeightedPool is IWeightedPool, BalancerPoolToken, PoolInfo, Version { // A minimum normalized weight imposes a maximum weight ratio. We need this due to limitations in the // implementation of the fixed point power function, as these ratios are often exponents. - uint256 private constant _MIN_WEIGHT = 1e16; // 1% + uint256 internal constant _MIN_WEIGHT = 1e16; // 1% uint256 private immutable _totalTokens; @@ -147,7 +147,7 @@ contract WeightedPool is IWeightedPool, BalancerPoolToken, PoolInfo, Version { } /// @inheritdoc IBasePool - function onSwap(PoolSwapParams memory request) public view onlyVault returns (uint256) { + function onSwap(PoolSwapParams memory request) public view virtual onlyVault returns (uint256) { uint256 balanceTokenInScaled18 = request.balancesScaled18[request.indexIn]; uint256 balanceTokenOutScaled18 = request.balancesScaled18[request.indexOut]; diff --git a/pkg/pool-weighted/contracts/WeightedPoolFactory.sol b/pkg/pool-weighted/contracts/WeightedPoolFactory.sol index 38d4dbf25..1383ccf21 100644 --- a/pkg/pool-weighted/contracts/WeightedPoolFactory.sol +++ b/pkg/pool-weighted/contracts/WeightedPoolFactory.sol @@ -20,8 +20,6 @@ import { WeightedPool } from "./WeightedPool.sol"; * @dev This is the most general factory, which allows up to eight tokens and arbitrary weights. */ contract WeightedPoolFactory is IPoolVersion, BasePoolFactory, Version { - // solhint-disable not-rely-on-time - string private _poolVersion; constructor( diff --git a/pkg/pool-weighted/contracts/lbp/LBPool.sol b/pkg/pool-weighted/contracts/lbp/LBPool.sol new file mode 100644 index 000000000..26d5fd527 --- /dev/null +++ b/pkg/pool-weighted/contracts/lbp/LBPool.sol @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; + +import { IBasePoolFactory } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePoolFactory.sol"; +import { IRouterCommon } from "@balancer-labs/v3-interfaces/contracts/vault/IRouterCommon.sol"; +import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol"; + +import { GradualValueChange } from "../lib/GradualValueChange.sol"; +import { WeightedPool } from "../WeightedPool.sol"; + +/** + * @notice Weighted Pool with mutable weights, designed to support v3 Liquidity Bootstrapping. + * @dev Inheriting from WeightedPool is only slightly wasteful (setting 2 immutable weights and `_totalTokens`, + * which will not be used later), and it is tremendously helpful for pool validation and any potential future + * base contract changes. + */ +contract LBPool is WeightedPool, Ownable2Step, BaseHooks { + using SafeCast for *; + + // Since we have max 2 tokens and the weights must sum to 1, we only need to store one weight. + // Weights are 18 decimal floating point values, which fit in less than 64 bits. Store smaller numeric values + // to ensure the PoolState fits in a single slot. All timestamps in the system are uint32, enforced through + // SafeCast. + struct PoolState { + uint32 startTime; + uint32 endTime; + uint64 startWeight0; + uint64 endWeight0; + bool swapEnabled; + } + + // LBPs are constrained to two tokens. + uint256 private constant _NUM_TOKENS = 2; + + // LBPools are deployed with the Balancer standard router address, which we know reliably reports the true + // originating account on operations. This is important for liquidity operations, as these are permissioned + // operations that can only be performed by the owner of the pool. Without this check, a malicious router + // could spoof the address of the owner, allowing anyone to call permissioned functions. + // + // Since the initialization mechanism does not allow verification of the router, it is technically possible + // to front-run `initialize`. This should not be a concern in the typical LBP use case of a new token launch, + // where there is no existing liquidity. In the unlikely event it is a concern, `LBPoolFactory` provides the + // `createAndInitialize` function, which does both operations in a single step. + + // solhint-disable-next-line var-name-mixedcase + address private immutable _trustedRouter; + + PoolState private _poolState; + + /** + * @notice Emitted when the owner enables or disables swaps. + * @param swapEnabled True if we are enabling swaps + */ + event SwapEnabledSet(bool swapEnabled); + + /** + * @notice Emitted when the owner initiates a gradual weight change (e.g., at the start of the sale). + * @dev Also emitted on deployment, recording the initial state. + * @param startTime The starting timestamp of the update + * @param endTime The ending timestamp of the update + * @param startWeights The weights at the start of the update + * @param endWeights The final weights after the update is completed + */ + event GradualWeightUpdateScheduled( + uint256 startTime, + uint256 endTime, + uint256[] startWeights, + uint256[] endWeights + ); + + /// @dev Indicates that the router that called the Vault is not trusted, so liquidity operations should revert. + error RouterNotTrusted(); + + /// @dev Indicates that the `owner` has disabled swaps. + error SwapsDisabled(); + + constructor( + NewPoolParams memory params, + IVault vault, + address owner, + bool swapEnabledOnStart, + address trustedRouter + ) WeightedPool(params, vault) Ownable(owner) { + // WeightedPool validates `numTokens == normalizedWeights.length`, and ensures valid weights. + // Here we additionally enforce that LBPs must be two-token pools. + InputHelpers.ensureInputLengthMatch(_NUM_TOKENS, params.numTokens); + + // Set the trusted router (passed down from the factory). + _trustedRouter = trustedRouter; + + // solhint-disable-next-line not-rely-on-time + uint32 currentTime = block.timestamp.toUint32(); + _startGradualWeightChange(currentTime, currentTime, params.normalizedWeights, params.normalizedWeights); + _setSwapEnabled(swapEnabledOnStart); + } + + /// @notice Returns the trusted router, which is the gateway to add liquidity to the pool. + function getTrustedRouter() external view returns (address) { + return _trustedRouter; + } + + /** + * @notice Return start time, end time, and endWeights as an array. + * @dev Current weights should be retrieved via `getNormalizedWeights()`. + * @return startTime The starting timestamp of any ongoing weight change + * @return endTime The ending timestamp of any ongoing weight change + * @return endWeights The "destination" weights, sorted in token registration order + */ + function getGradualWeightUpdateParams() + external + view + returns (uint256 startTime, uint256 endTime, uint256[] memory endWeights) + { + PoolState memory poolState = _poolState; + + startTime = poolState.startTime; + endTime = poolState.endTime; + + endWeights = new uint256[](_NUM_TOKENS); + endWeights[0] = poolState.endWeight0; + endWeights[1] = FixedPoint.ONE - poolState.endWeight0; + } + + /** + * @notice Indicate whether swaps are enabled or not for the given pool. + * @return swapEnabled True if trading is enabled + */ + function getSwapEnabled() external view returns (bool) { + return _getPoolSwapEnabled(); + } + + /******************************************************************************* + Permissioned Functions + *******************************************************************************/ + + /** + * @notice Enable/disable trading. + * @dev This is a permissioned function that can only be called by the owner. + * @param swapEnabled True if trading should be enabled + */ + function setSwapEnabled(bool swapEnabled) external onlyOwner { + _setSwapEnabled(swapEnabled); + } + + /** + * @notice Start a gradual weight change. Weights will change smoothly from current values to `endWeights`. + * @dev This is a permissioned function that can only be called by the owner. + * If the `startTime` is in the past, the weight change will begin immediately. + * + * @param startTime The timestamp when the weight change will start + * @param endTime The timestamp when the weights will reach their final values + * @param endWeights The final values of the weights + */ + function updateWeightsGradually( + uint256 startTime, + uint256 endTime, + uint256[] memory endWeights + ) external onlyOwner { + InputHelpers.ensureInputLengthMatch(_NUM_TOKENS, endWeights.length); + + if (endWeights[0] < _MIN_WEIGHT || endWeights[1] < _MIN_WEIGHT) { + revert MinWeight(); + } + if (endWeights[0] + endWeights[1] != FixedPoint.ONE) { + revert NormalizedWeightInvariant(); + } + + // Ensure startTime >= now. + startTime = GradualValueChange.resolveStartTime(startTime, endTime); + + // The SafeCast ensures `endTime` can't overflow. + _startGradualWeightChange(startTime.toUint32(), endTime.toUint32(), _getNormalizedWeights(), endWeights); + } + + /// @inheritdoc WeightedPool + function onSwap(PoolSwapParams memory request) public view override onlyVault returns (uint256) { + if (!_getPoolSwapEnabled()) { + revert SwapsDisabled(); + } + return super.onSwap(request); + } + + /******************************************************************************* + Hook Functions + *******************************************************************************/ + + /** + * @notice Hook to be executed when pool is registered. + * @dev Returns true if registration was successful, and false to revert the registration of the pool. + * @param pool Address of the pool + * @return success True if the hook allowed the registration, false otherwise + */ + function onRegister( + address, + address pool, + TokenConfig[] memory tokenConfig, + LiquidityManagement calldata + ) public view override onlyVault returns (bool) { + InputHelpers.ensureInputLengthMatch(_NUM_TOKENS, tokenConfig.length); + + return pool == address(this); + } + + // Return HookFlags struct that indicates which hooks this contract supports + function getHookFlags() public pure override returns (HookFlags memory hookFlags) { + // Ensure the caller is the owner, as only the owner can add liquidity. + hookFlags.shouldCallBeforeAddLiquidity = true; + } + + /** + * @notice Check that the caller who initiated the add liquidity operation is the owner. + * @dev We first ensure the caller is the standard router, so that we know we can trust the value it returns + * from `getSender`. + * + * @param router The address (usually a router contract) that initiated the add liquidity operation + */ + function onBeforeAddLiquidity( + address router, + address, + AddLiquidityKind, + uint256[] memory, + uint256, + uint256[] memory, + bytes memory + ) public view override onlyVault returns (bool) { + if (router != _trustedRouter) { + revert RouterNotTrusted(); + } + return IRouterCommon(router).getSender() == owner(); + } + + /******************************************************************************* + Internal Functions + *******************************************************************************/ + + // This is unused in this contract, but must be overridden from WeightedPool for consistency. + function _getNormalizedWeight(uint256 tokenIndex) internal view virtual override returns (uint256) { + if (tokenIndex < _NUM_TOKENS) { + return _getNormalizedWeights()[tokenIndex]; + } + + revert IVaultErrors.InvalidToken(); + } + + function _getNormalizedWeights() internal view override returns (uint256[] memory) { + uint256[] memory normalizedWeights = new uint256[](_NUM_TOKENS); + normalizedWeights[0] = _getNormalizedWeight0(); + normalizedWeights[1] = FixedPoint.ONE - normalizedWeights[0]; + + return normalizedWeights; + } + + function _getNormalizedWeight0() internal view virtual returns (uint256) { + PoolState memory poolState = _poolState; + uint256 pctProgress = GradualValueChange.calculateValueChangeProgress(poolState.startTime, poolState.endTime); + + return GradualValueChange.interpolateValue(poolState.startWeight0, poolState.endWeight0, pctProgress); + } + + function _getPoolSwapEnabled() private view returns (bool) { + return _poolState.swapEnabled; + } + + function _setSwapEnabled(bool swapEnabled) private { + _poolState.swapEnabled = swapEnabled; + + emit SwapEnabledSet(swapEnabled); + } + + /** + * @dev When calling updateWeightsGradually again during an update, reset the start weights to the current weights, + * if necessary. + */ + function _startGradualWeightChange( + uint32 startTime, + uint32 endTime, + uint256[] memory startWeights, + uint256[] memory endWeights + ) internal virtual { + PoolState memory poolState = _poolState; + + poolState.startTime = startTime; + poolState.endTime = endTime; + + // These have been validated, but SafeCast anyway out of an abundance of caution. + poolState.startWeight0 = startWeights[0].toUint64(); + poolState.endWeight0 = endWeights[0].toUint64(); + + _poolState = poolState; + + emit GradualWeightUpdateScheduled(startTime, endTime, startWeights, endWeights); + } +} diff --git a/pkg/pool-weighted/contracts/lbp/LBPoolFactory.sol b/pkg/pool-weighted/contracts/lbp/LBPoolFactory.sol new file mode 100644 index 000000000..13e54dbcc --- /dev/null +++ b/pkg/pool-weighted/contracts/lbp/LBPoolFactory.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +import { IPoolVersion } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IPoolVersion.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { TokenConfig, PoolRoleAccounts } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { BasePoolFactory } from "@balancer-labs/v3-pool-utils/contracts/BasePoolFactory.sol"; +import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.sol"; +import { Version } from "@balancer-labs/v3-solidity-utils/contracts/helpers/Version.sol"; +import { + ReentrancyGuardTransient +} from "@balancer-labs/v3-solidity-utils/contracts/openzeppelin/ReentrancyGuardTransient.sol"; + +import { WeightedPool } from "../WeightedPool.sol"; +import { LBPool } from "./LBPool.sol"; + +/** + * @notice LBPool Factory. + * @dev This is a factory specific to LBPools, allowing only 2 tokens. + */ +contract LBPoolFactory is IPoolVersion, ReentrancyGuardTransient, BasePoolFactory, Version { + using SafeERC20 for IERC20; + using SafeCast for uint256; + + // LBPs are constrained to two tokens. + uint256 private constant _NUM_TOKENS = 2; + + string private _poolVersion; + + address internal immutable _trustedRouter; + IPermit2 internal immutable _permit2; + + constructor( + IVault vault, + uint32 pauseWindowDuration, + string memory factoryVersion, + string memory poolVersion, + address trustedRouter, + IPermit2 permit2 + ) BasePoolFactory(vault, pauseWindowDuration, type(LBPool).creationCode) Version(factoryVersion) { + _poolVersion = poolVersion; + + // LBPools are deployed with a router known to reliably report the originating address on operations. + _trustedRouter = trustedRouter; + _permit2 = permit2; + } + + /// @inheritdoc IPoolVersion + function getPoolVersion() external view returns (string memory) { + return _poolVersion; + } + + /// @notice Returns trusted router, which is the gateway to add liquidity to the pool. + function getTrustedRouter() external view returns (address) { + return _trustedRouter; + } + + /// @notice Returns permit2 address, used to initialize pools. + function getPermit2() external view returns (IPermit2) { + return _permit2; + } + + /** + * @notice Deploys a new `LBPool`. + * @dev Tokens must be sorted for pool registration. + * @param name The name of the pool + * @param symbol The symbol of the pool + * @param tokenConfig An array of descriptors for the tokens the pool will manage + * @param normalizedWeights The pool weights (must add to FixedPoint.ONE) + * @param swapFeePercentage Initial swap fee percentage + * @param owner The owner address for pool; sole LP with swapEnable/swapFee change permissions + * @param salt The salt value that will be passed to create3 deployment + */ + function create( + string memory name, + string memory symbol, + TokenConfig[] memory tokenConfig, + uint256[] memory normalizedWeights, + uint256 swapFeePercentage, + address owner, + bool swapEnabledOnStart, + bytes32 salt + ) external nonReentrant returns (address pool) { + return + _create(name, symbol, tokenConfig, normalizedWeights, swapFeePercentage, owner, swapEnabledOnStart, salt); + } + + /** + * @notice Deploys a new `LBPool` and seeds it with initial liquidity in the same tx. + * @dev Tokens must be sorted for pool registration. + * Use this method in case pool initialization frontrunning is an issue. + * If the owner is the only address with liquidity of one of the tokens, this should not be necessary. + * This method does not support native ETH management; WETH needs to be used instead. + + * @param name The name of the pool + * @param symbol The symbol of the pool + * @param tokenConfig An array of descriptors for the tokenConfig the pool will manage + * @param normalizedWeights The pool weights (must add to FixedPoint.ONE) + * @param swapFeePercentage Initial swap fee percentage + * @param owner The owner address for pool; sole LP with swapEnable/swapFee change permissions + * @param salt The salt value that will be passed to create3 deployment + * @param exactAmountsIn Token amounts in, matching token order + */ + function createAndInitialize( + string memory name, + string memory symbol, + TokenConfig[] memory tokenConfig, + uint256[] memory normalizedWeights, + uint256 swapFeePercentage, + address owner, + bool swapEnabledOnStart, + bytes32 salt, + uint256[] memory exactAmountsIn + ) external nonReentrant returns (address pool) { + // `create` checks token config length already + pool = _create( + name, + symbol, + tokenConfig, + normalizedWeights, + swapFeePercentage, + owner, + swapEnabledOnStart, + salt + ); + + IERC20[] memory tokens = new IERC20[](_NUM_TOKENS); + for (uint256 i = 0; i < _NUM_TOKENS; ++i) { + tokens[i] = tokenConfig[i].token; + + // Pull necessary tokens and approve permit2 to use them via the router + tokens[i].safeTransferFrom(msg.sender, address(this), exactAmountsIn[i]); + tokens[i].forceApprove(address(_permit2), exactAmountsIn[i]); + _permit2.approve( + address(tokens[i]), + address(_trustedRouter), + exactAmountsIn[i].toUint160(), + type(uint48).max + ); + } + + IRouter(_trustedRouter).initialize(pool, tokens, exactAmountsIn, 0, false, ""); + } + + function _create( + string memory name, + string memory symbol, + TokenConfig[] memory tokenConfig, + uint256[] memory normalizedWeights, + uint256 swapFeePercentage, + address owner, + bool swapEnabledOnStart, + bytes32 salt + ) internal returns (address pool) { + InputHelpers.ensureInputLengthMatch(_NUM_TOKENS, tokenConfig.length); + InputHelpers.ensureInputLengthMatch(_NUM_TOKENS, normalizedWeights.length); + + PoolRoleAccounts memory roleAccounts; + // It's not necessary to set the pauseManager, as the owner can already effectively pause the pool by disabling + // swaps. There is also no poolCreator, as the owner is already using this to earn revenue directly. + roleAccounts.swapFeeManager = owner; + + pool = _create( + abi.encode( + WeightedPool.NewPoolParams({ + name: name, + symbol: symbol, + numTokens: tokenConfig.length, + normalizedWeights: normalizedWeights, + version: _poolVersion + }), + getVault(), + owner, + swapEnabledOnStart, + _trustedRouter + ), + salt + ); + + _registerPoolWithVault( + pool, + tokenConfig, + swapFeePercentage, + false, // protocol fee exempt + roleAccounts, + pool, // register the pool itself as the hook contract + getDefaultLiquidityManagement() + ); + } +} diff --git a/pkg/pool-weighted/contracts/lib/GradualValueChange.sol b/pkg/pool-weighted/contracts/lib/GradualValueChange.sol new file mode 100644 index 000000000..5e2dfa733 --- /dev/null +++ b/pkg/pool-weighted/contracts/lib/GradualValueChange.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +pragma solidity ^0.8.24; + +// solhint-disable not-rely-on-time + +library GradualValueChange { + /// @dev Indicates that the start time is after the end time + error GradualUpdateTimeTravel(uint256 resolvedStartTime, uint256 endTime); + + using FixedPoint for uint256; + + function getInterpolatedValue( + uint256 startValue, + uint256 endValue, + uint256 startTime, + uint256 endTime + ) internal view returns (uint256) { + uint256 pctProgress = calculateValueChangeProgress(startTime, endTime); + + return interpolateValue(startValue, endValue, pctProgress); + } + + function resolveStartTime(uint256 startTime, uint256 endTime) internal view returns (uint256 resolvedStartTime) { + // If the start time is in the past, "fast forward" to start now + // This avoids discontinuities in the value curve. Otherwise, if you set the start/end times with + // only 10% of the period in the future, the value would immediately jump 90% + resolvedStartTime = Math.max(block.timestamp, startTime); + + if (resolvedStartTime > endTime) { + revert GradualUpdateTimeTravel(resolvedStartTime, endTime); + } + } + + function interpolateValue( + uint256 startValue, + uint256 endValue, + uint256 pctProgress + ) internal pure returns (uint256) { + if (pctProgress >= FixedPoint.ONE || startValue == endValue) { + return endValue; + } + + if (pctProgress == 0) { + return startValue; + } + + unchecked { + if (startValue > endValue) { + uint256 delta = pctProgress.mulDown(startValue - endValue); + return startValue - delta; + } else { + uint256 delta = pctProgress.mulDown(endValue - startValue); + return startValue + delta; + } + } + } + + /** + * @dev Returns a fixed-point number representing how far along the current value change is, where 0 means the + * change has not yet started, and FixedPoint.ONE means it has fully completed. + */ + function calculateValueChangeProgress(uint256 startTime, uint256 endTime) internal view returns (uint256) { + if (block.timestamp >= endTime) { + return FixedPoint.ONE; + } else if (block.timestamp <= startTime) { + return 0; + } + + // No need for checked math as the magnitudes are verified above: endTime > block.timestamp > startTime + uint256 totalSeconds; + uint256 secondsElapsed; + + unchecked { + totalSeconds = endTime - startTime; + secondsElapsed = block.timestamp - startTime; + } + + // We don't need to consider zero division here as this is covered above. + return secondsElapsed.divDown(totalSeconds); + } +} diff --git a/pkg/pool-weighted/contracts/test/GradualValueChangeMock.sol b/pkg/pool-weighted/contracts/test/GradualValueChangeMock.sol new file mode 100644 index 000000000..13f469f63 --- /dev/null +++ b/pkg/pool-weighted/contracts/test/GradualValueChangeMock.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import "../lib/GradualValueChange.sol"; + +contract GradualValueChangeMock { + function getInterpolatedValue( + uint256 startValue, + uint256 endValue, + uint256 startTime, + uint256 endTime + ) public view returns (uint256) { + return GradualValueChange.getInterpolatedValue(startValue, endValue, startTime, endTime); + } + + function resolveStartTime(uint256 startTime, uint256 endTime) public view returns (uint256) { + return GradualValueChange.resolveStartTime(startTime, endTime); + } + + function interpolateValue(uint256 startValue, uint256 endValue, uint256 pctProgress) public pure returns (uint256) { + return GradualValueChange.interpolateValue(startValue, endValue, pctProgress); + } + + function calculateValueChangeProgress(uint256 startTime, uint256 endTime) public view returns (uint256) { + return GradualValueChange.calculateValueChangeProgress(startTime, endTime); + } +} diff --git a/pkg/pool-weighted/test/LBPool.test.ts b/pkg/pool-weighted/test/LBPool.test.ts new file mode 100644 index 000000000..53f1366a5 --- /dev/null +++ b/pkg/pool-weighted/test/LBPool.test.ts @@ -0,0 +1,349 @@ +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { deploy, deployedAt } from '@balancer-labs/v3-helpers/src/contract'; +import { sharedBeforeEach } from '@balancer-labs/v3-common/sharedBeforeEach'; +import { Router } from '@balancer-labs/v3-vault/typechain-types/contracts/Router'; +import { ERC20TestToken } from '@balancer-labs/v3-solidity-utils/typechain-types/contracts/test/ERC20TestToken'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/dist/src/signer-with-address'; +import { FP_ZERO, fp } from '@balancer-labs/v3-helpers/src/numbers'; +import { MAX_UINT256, MAX_UINT160, MAX_UINT48, ZERO_BYTES32 } from '@balancer-labs/v3-helpers/src/constants'; +import * as VaultDeployer from '@balancer-labs/v3-helpers/src/models/vault/VaultDeployer'; +import { IVaultMock } from '@balancer-labs/v3-interfaces/typechain-types'; +import TypesConverter from '@balancer-labs/v3-helpers/src/models/types/TypesConverter'; +import { buildTokenConfig } from '@balancer-labs/v3-helpers/src/models/tokens/tokenConfig'; +import { LBPool, LBPoolFactory } from '../typechain-types'; +import { actionId } from '@balancer-labs/v3-helpers/src/models/misc/actions'; +import { MONTH, MINUTE } from '@balancer-labs/v3-helpers/src/time'; +import * as expectEvent from '@balancer-labs/v3-helpers/src/test/expectEvent'; +import { sortAddresses } from '@balancer-labs/v3-helpers/src/models/tokens/sortingHelper'; +import { deployPermit2 } from '@balancer-labs/v3-vault/test/Permit2Deployer'; +import { IPermit2 } from '@balancer-labs/v3-vault/typechain-types/permit2/src/interfaces/IPermit2'; +import { PoolConfigStructOutput } from '@balancer-labs/v3-solidity-utils/typechain-types/@balancer-labs/v3-interfaces/contracts/vault/IVault'; +import { TokenConfigStruct } from '../typechain-types/@balancer-labs/v3-interfaces/contracts/vault/IVault'; +import { time } from '@nomicfoundation/hardhat-network-helpers'; + +describe('LBPool', function () { + const POOL_SWAP_FEE = fp(0.01); + + const TOKEN_AMOUNT = fp(100); + + let permit2: IPermit2; + let vault: IVaultMock; + let factory: LBPoolFactory; + let pool: LBPool; + let router: Router; + let alice: SignerWithAddress; + let bob: SignerWithAddress; + let tokenA: ERC20TestToken; + let tokenB: ERC20TestToken; + let poolTokens: string[]; + + let tokenAIdx: number; + let tokenBIdx: number; + + let tokenAAddress: string; + let tokenBAddress: string; + + const FACTORY_VERSION = 'LBPool Factory v1'; + const POOL_VERSION = 'LBPool v1'; + const ROUTER_VERSION = 'Router v11'; + + const WEIGHTS = [fp(0.5), fp(0.5)]; + const INITIAL_BALANCES = [TOKEN_AMOUNT, TOKEN_AMOUNT]; + const SWAP_AMOUNT = fp(20); + + const SWAP_FEE = fp(0.01); + + before('setup signers', async () => { + [, alice, bob] = await ethers.getSigners(); + }); + + sharedBeforeEach('deploy vault, router, tokens, and pool', async function () { + vault = await TypesConverter.toIVaultMock(await VaultDeployer.deployMock()); + + const WETH = await deploy('v3-solidity-utils/WETHTestToken'); + permit2 = await deployPermit2(); + router = await deploy('v3-vault/Router', { args: [vault, WETH, permit2, ROUTER_VERSION] }); + + tokenA = await deploy('v3-solidity-utils/ERC20TestToken', { args: ['Token A', 'TKNA', 18] }); + tokenB = await deploy('v3-solidity-utils/ERC20TestToken', { args: ['Token B', 'TKNB', 6] }); + + tokenAAddress = await tokenA.getAddress(); + tokenBAddress = await tokenB.getAddress(); + + tokenAIdx = tokenAAddress < tokenBAddress ? 0 : 1; + tokenBIdx = tokenAAddress < tokenBAddress ? 1 : 0; + }); + + sharedBeforeEach('create and initialize pool', async () => { + factory = await deploy('LBPoolFactory', { + args: [await vault.getAddress(), MONTH * 12, FACTORY_VERSION, POOL_VERSION, router, permit2], + }); + poolTokens = sortAddresses([tokenAAddress, tokenBAddress]); + + const tokenConfig: TokenConfigStruct[] = buildTokenConfig(poolTokens); + + const tx = await factory.create( + 'LBPool', + 'Test', + tokenConfig, + WEIGHTS, + SWAP_FEE, + bob.address, // owner + true, // swapEnabledOnStart + ZERO_BYTES32 + ); + const receipt = await tx.wait(); + const event = expectEvent.inReceipt(receipt, 'PoolCreated'); + + pool = (await deployedAt('LBPool', event.args.pool)) as unknown as LBPool; + + await tokenA.mint(bob, TOKEN_AMOUNT + SWAP_AMOUNT); + await tokenB.mint(bob, TOKEN_AMOUNT); + + await pool.connect(bob).approve(router, MAX_UINT256); + for (const token of [tokenA, tokenB]) { + await token.connect(bob).approve(permit2, MAX_UINT256); + await permit2.connect(bob).approve(token, router, MAX_UINT160, MAX_UINT48); + } + + await expect(await router.connect(bob).initialize(pool, poolTokens, INITIAL_BALANCES, FP_ZERO, false, '0x')) + .to.emit(vault, 'PoolInitialized') + .withArgs(pool); + }); + + sharedBeforeEach('grant permission', async () => { + const setPoolSwapFeeAction = await actionId(vault, 'setStaticSwapFeePercentage'); + + const authorizerAddress = await vault.getAuthorizer(); + const authorizer = await deployedAt('v3-vault/BasicAuthorizerMock', authorizerAddress); + + await authorizer.grantRole(setPoolSwapFeeAction, bob.address); + + await vault.connect(bob).setStaticSwapFeePercentage(pool, POOL_SWAP_FEE); + }); + + it('should have correct versions', async () => { + expect(await factory.version()).to.eq(FACTORY_VERSION); + expect(await factory.getPoolVersion()).to.eq(POOL_VERSION); + expect(await pool.version()).to.eq(POOL_VERSION); + }); + + it('pool and protocol fee preconditions', async () => { + const poolConfig: PoolConfigStructOutput = await vault.getPoolConfig(pool); + + expect(poolConfig.isPoolRegistered).to.be.true; + expect(poolConfig.isPoolInitialized).to.be.true; + + expect(await vault.getStaticSwapFeePercentage(pool)).to.eq(POOL_SWAP_FEE); + }); + + it('has the correct pool tokens and balances', async () => { + const tokensFromPool = await pool.getTokens(); + expect(tokensFromPool).to.deep.equal(poolTokens); + + const [tokensFromVault, , balancesFromVault] = await vault.getPoolTokenInfo(pool); + + expect(tokensFromVault).to.deep.equal(tokensFromPool); + expect(balancesFromVault).to.deep.equal(INITIAL_BALANCES); + }); + + it('cannot be initialized twice', async () => { + await expect(router.connect(alice).initialize(pool, poolTokens, INITIAL_BALANCES, FP_ZERO, false, '0x')) + .to.be.revertedWithCustomError(vault, 'PoolAlreadyInitialized') + .withArgs(await pool.getAddress()); + }); + + it('returns weights', async () => { + const weights = await pool.getNormalizedWeights(); + expect(weights).to.be.deep.eq(WEIGHTS); + }); + + describe('Owner operations and events', () => { + it('should emit SwapEnabledSet event when setSwapEnabled is called', async () => { + await expect(pool.connect(bob).setSwapEnabled(false)).to.emit(pool, 'SwapEnabledSet').withArgs(false); + + await expect(pool.connect(bob).setSwapEnabled(true)).to.emit(pool, 'SwapEnabledSet').withArgs(true); + }); + + it('should emit GradualWeightUpdateScheduled event when updateWeightsGradually is called', async () => { + const startTime = await time.latest(); + const endTime = startTime + MONTH; + const endWeights = [fp(0.7), fp(0.3)]; + + const tx = await pool.connect(bob).updateWeightsGradually(startTime, endTime, endWeights); + const actualStartTime = await time.latest(); + + await expect(tx) + .to.emit(pool, 'GradualWeightUpdateScheduled') + .withArgs(actualStartTime, endTime, WEIGHTS, endWeights); + }); + + it('should only allow owner to be the LP', async () => { + const amounts: bigint[] = [FP_ZERO, FP_ZERO]; + amounts[tokenAIdx] = SWAP_AMOUNT; + + await expect(router.addLiquidityUnbalanced(pool, amounts, FP_ZERO, false, '0x')).to.be.revertedWithCustomError( + vault, + 'BeforeAddLiquidityHookFailed' + ); + + await router.connect(bob).addLiquidityUnbalanced(pool, amounts, FP_ZERO, false, '0x'); + }); + + it('should only allow owner to update weights', async () => { + const startTime = await time.latest(); + const endTime = startTime + MONTH; + const endWeights = [fp(0.7), fp(0.3)]; + + await expect( + pool.connect(alice).updateWeightsGradually(startTime, endTime, endWeights) + ).to.be.revertedWithCustomError(pool, 'OwnableUnauthorizedAccount'); + + await expect(pool.connect(bob).updateWeightsGradually(startTime, endTime, endWeights)).to.not.be.reverted; + }); + }); + + describe('Weight updates', () => { + it('should update weights gradually', async () => { + const startTime = await time.latest(); + const endTime = startTime + MONTH; + const endWeights = [fp(0.7), fp(0.3)]; + + await pool.connect(bob).updateWeightsGradually(startTime, endTime, endWeights); + + // Check weights at start + expect(await pool.getNormalizedWeights()).to.deep.equal(WEIGHTS); + + // Check weights halfway through + await time.increaseTo(startTime + MONTH / 2); + const midWeights = await pool.getNormalizedWeights(); + expect(midWeights[0]).to.be.closeTo(fp(0.6), fp(1e-6)); + expect(midWeights[1]).to.be.closeTo(fp(0.4), fp(1e-6)); + + // Check weights at end + await time.increaseTo(endTime); + expect(await pool.getNormalizedWeights()).to.deep.equal(endWeights); + }); + + it('should constrain weights to [1%, 99%]', async () => { + const startTime = await time.latest(); + const endTime = startTime + MONTH; + + // Try to set weight below 1% + await expect( + pool.connect(bob).updateWeightsGradually(startTime, endTime, [fp(0.009), fp(0.991)]) + ).to.be.revertedWithCustomError(pool, 'MinWeight'); + + // Try to set weight above 99% + await expect( + pool.connect(bob).updateWeightsGradually(startTime, endTime, [fp(0.991), fp(0.009)]) + ).to.be.revertedWithCustomError(pool, 'MinWeight'); + + // Valid weight update + await expect(pool.connect(bob).updateWeightsGradually(startTime, endTime, [fp(0.01), fp(0.99)])).to.not.be + .reverted; + }); + + it('should always sum weights to 1', async () => { + const currentTime = await time.latest(); + const startTime = currentTime + MINUTE; // Set startTime 1 min in the future + const endTime = startTime + MONTH; + const startWeights = [fp(0.5), fp(0.5)]; + const endWeights = [fp(0.7), fp(0.3)]; + + // Move time to just before startTime + await time.increaseTo(startTime - 1); + + // Set weights to 50/50 instantaneously + const tx1 = await pool.connect(bob).updateWeightsGradually(startTime, startTime, startWeights); + await tx1.wait(); + + // Schedule gradual shift to 70/30 + const tx2 = await pool.connect(bob).updateWeightsGradually(startTime, endTime, endWeights); + await tx2.wait(); + + // Check weights at various points during the transition + for (let i = 0; i <= 100; i++) { + const checkTime = startTime + (i * MONTH) / 100; + + // Only increase time if it's greater than the current time + const currentBlockTime = await time.latest(); + if (checkTime > currentBlockTime) { + await time.increaseTo(checkTime); + } + + const weights = await pool.getNormalizedWeights(); + const sum = (BigInt(weights[0].toString()) + BigInt(weights[1].toString())).toString(); + + // Assert exact equality + expect(sum).to.equal(fp(1)); + } + }); + }); + + describe('Setters and Getters', () => { + it('should set and get swap enabled status', async () => { + await pool.connect(bob).setSwapEnabled(false); + expect(await pool.getSwapEnabled()).to.be.false; + + await pool.connect(bob).setSwapEnabled(true); + expect(await pool.getSwapEnabled()).to.be.true; + }); + + it('should get gradual weight update params', async () => { + const startTime = await time.latest(); + const endTime = startTime + MONTH; + const endWeights = [fp(0.7), fp(0.3)]; + + const tx = await pool.connect(bob).updateWeightsGradually(startTime, endTime, endWeights); + await tx.wait(); + const actualStartTime = await time.latest(); + + const params = await pool.getGradualWeightUpdateParams(); + expect(params.startTime).to.equal(actualStartTime); + expect(params.endTime).to.equal(endTime); + expect(params.endWeights).to.deep.equal(endWeights); + }); + }); + + describe('Swap restrictions', () => { + it('should allow swaps when enabled', async () => { + await expect( + router + .connect(bob) + .swapSingleTokenExactIn( + pool, + poolTokens[tokenAIdx], + poolTokens[tokenBIdx], + SWAP_AMOUNT, + 0, + MAX_UINT256, + false, + '0x' + ) + ).to.not.be.reverted; + }); + + it('should not allow swaps when disabled', async () => { + await expect(await pool.connect(bob).setSwapEnabled(false)) + .to.emit(pool, 'SwapEnabledSet') + .withArgs(false); + + await expect( + router + .connect(bob) + .swapSingleTokenExactIn( + pool, + poolTokens[tokenAIdx], + poolTokens[tokenBIdx], + SWAP_AMOUNT, + 0, + MAX_UINT256, + false, + '0x' + ) + ).to.be.reverted; + }); + }); +}); diff --git a/pkg/pool-weighted/test/WeightedPool.test.ts b/pkg/pool-weighted/test/WeightedPool.test.ts index 6a724394b..3a044b196 100644 --- a/pkg/pool-weighted/test/WeightedPool.test.ts +++ b/pkg/pool-weighted/test/WeightedPool.test.ts @@ -30,7 +30,7 @@ import { TokenConfigStruct } from '../typechain-types/@balancer-labs/v3-interfac describe('WeightedPool', function () { const FACTORY_VERSION = 'Weighted Factory v1'; const POOL_VERSION = 'Weighted Pool v1'; - const ROUTER_VERSION = 'Router v9'; + const ROUTER_VERSION = 'Router v11'; const POOL_SWAP_FEE = fp(0.01); const TOKEN_AMOUNT = fp(100); diff --git a/pkg/pool-weighted/test/foundry/GradualValueChange.t.sol b/pkg/pool-weighted/test/foundry/GradualValueChange.t.sol new file mode 100644 index 000000000..7afcec209 --- /dev/null +++ b/pkg/pool-weighted/test/foundry/GradualValueChange.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import "../../contracts/test/GradualValueChangeMock.sol"; + +contract GradualValueChangeTest is Test { + uint256 private constant FP_ONE = 1e18; + + GradualValueChangeMock private mock; + + function setUp() public { + mock = new GradualValueChangeMock(); + } + + function testGetInterpolatedValue() public { + uint256 startValue = 100e18; + uint256 endValue = 200e18; + uint256 startTime = block.timestamp; + uint256 endTime = startTime + 100; + uint256 steps = 100; + + for (uint256 i = 0; i <= steps; i++) { + uint256 currentTime = startTime + ((i * (endTime - startTime)) / steps); + vm.warp(currentTime); + uint256 expectedValue = startValue + ((i * (endValue - startValue)) / steps); + uint256 actualValue = mock.getInterpolatedValue(startValue, endValue, startTime, endTime); + assertEq(actualValue, expectedValue, "Interpolated value should match expected"); + } + } + + function testResolveStartTime() public { + uint256 currentTime = 1000000; + uint256 futureTime = currentTime + 100; + + vm.warp(currentTime); + assertEq(mock.resolveStartTime(futureTime, futureTime + 100), futureTime, "Should return future start time"); + assertEq( + mock.resolveStartTime(currentTime - 100, futureTime), + currentTime, + "Should return current time for past start time" + ); + + vm.expectRevert( + abi.encodeWithSelector( + GradualValueChange.GradualUpdateTimeTravel.selector, + futureTime + 200, + futureTime + 100 + ) + ); + mock.resolveStartTime(futureTime + 200, futureTime + 100); + } + + function testInterpolateValue() public view { + uint256 startValue = 100e18; + uint256 endValue = 200e18; + uint256 steps = 100; + + for (uint256 i = 0; i <= steps; i++) { + uint256 pctProgress = (i * FP_ONE) / steps; + uint256 expectedValue = startValue + ((i * (endValue - startValue)) / steps); + uint256 actualValue = mock.interpolateValue(startValue, endValue, pctProgress); + assertEq(actualValue, expectedValue, "Interpolated value should match expected"); + } + + // Test decreasing value + startValue = 200e18; + endValue = 100e18; + + for (uint256 i = 0; i <= steps; i++) { + uint256 pctProgress = (i * FP_ONE) / steps; + uint256 expectedValue = startValue - ((i * (startValue - endValue)) / steps); + uint256 actualValue = mock.interpolateValue(startValue, endValue, pctProgress); + assertEq(actualValue, expectedValue, "Interpolated value should match expected for decreasing value"); + } + } + + function testCalculateValueChangeProgress() public { + uint256 startTime = block.timestamp; + uint256 endTime = startTime + 100; + uint256 steps = 100; + + for (uint256 i = 0; i <= steps; i++) { + uint256 currentTime = startTime + ((i * (endTime - startTime)) / steps); + vm.warp(currentTime); + uint256 expectedProgress = (i * FP_ONE) / steps; + uint256 actualProgress = mock.calculateValueChangeProgress(startTime, endTime); + // Use a very tight tolerance for progress calculation + assertApproxEqAbs(actualProgress, expectedProgress, 1, "Progress should be very close to expected"); + } + + vm.warp(endTime + 50); + assertEq(mock.calculateValueChangeProgress(startTime, endTime), FP_ONE, "Should be complete after end time"); + } + + function testEdgeCases() public { + uint256 startTime = block.timestamp; + uint256 endTime = startTime + 100; + + uint256 startValue = 100e18; + uint256 endValue = 200e18; + + for (uint256 i = 0; i <= 100; i++) { + vm.warp(startTime + i); + assertEq( + mock.getInterpolatedValue(startValue, startValue, startTime, endTime), + startValue, + "Should always return the same value" + ); + } + + // Test before start time + vm.warp(startTime - 1); + assertEq( + mock.getInterpolatedValue(startValue, endValue, startTime, startTime), + startValue, + "Should return start value before start time for zero duration" + ); + + // Test at start time + vm.warp(startTime); + assertEq( + mock.getInterpolatedValue(startValue, endValue, startTime, startTime), + endValue, + "Should return end value at start time for zero duration" + ); + + // Test after start time + vm.warp(startTime + 1); + assertEq( + mock.getInterpolatedValue(startValue, endValue, startTime, startTime), + endValue, + "Should return end value after start time for zero duration" + ); + + uint256 bigVal = 1e18 * FP_ONE; //1 quintillion w/ 18 decimals + + // Test exact endpoints + vm.warp(0); + assertEq(mock.getInterpolatedValue(0, bigVal, 0, bigVal), 0, "Should be 0 at start time"); + vm.warp(bigVal); + assertEq(mock.getInterpolatedValue(0, bigVal, 0, bigVal), bigVal, "Should be bigVal at end time"); + + // Test intermediate points + uint256 steps = 1e4; + for (uint256 i = 0; i <= steps; i++) { + uint256 currentTime = (bigVal / steps) * i; + vm.warp(currentTime); + uint256 expectedValue = (bigVal / steps) * i; + + uint256 actualValue; + try mock.getInterpolatedValue(0, bigVal, 0, bigVal) returns (uint256 value) { + actualValue = value; + assertApproxEqRel(actualValue, expectedValue, 1, "Should be close for large numbers"); + } catch Error(string memory reason) { + revert(string(abi.encodePacked("getInterpolatedValue reverted: ", reason))); + } catch (bytes memory /*lowLevelData*/) { + revert("getInterpolatedValue reverted unexpectedly"); + } + + // Additional check to ensure the value is within the expected range + assertGe(actualValue, 0, "Value should not be less than 0"); + assertLe(actualValue, bigVal, "Value should not exceed bigVal"); + } + } +} diff --git a/pkg/pool-weighted/test/foundry/LBPool.t.sol b/pkg/pool-weighted/test/foundry/LBPool.t.sol new file mode 100644 index 000000000..2260a604c --- /dev/null +++ b/pkg/pool-weighted/test/foundry/LBPool.t.sol @@ -0,0 +1,712 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +import { IBasePoolFactory } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePoolFactory.sol"; +import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; +import { IBasePool } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePool.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { + TokenConfig, + PoolRoleAccounts, + PoolSwapParams, + SwapKind +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol"; +import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.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 { BasePoolTest } from "@balancer-labs/v3-vault/test/foundry/utils/BasePoolTest.sol"; +import { PoolHooksMock } from "@balancer-labs/v3-vault/contracts/test/PoolHooksMock.sol"; +import { RouterMock } from "@balancer-labs/v3-vault/contracts/test/RouterMock.sol"; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { LBPoolFactory } from "../../contracts/lbp/LBPoolFactory.sol"; +import { WeightedPool } from "../../contracts/WeightedPool.sol"; +import { LBPool } from "../../contracts/lbp/LBPool.sol"; + +contract LBPoolTest is BasePoolTest { + using CastingHelpers for address[]; + using ArrayHelpers for *; + using FixedPoint for uint256; + + uint256 constant DEFAULT_SWAP_FEE = 1e16; // 1% + uint256 constant TOKEN_AMOUNT = 1e3 * 1e18; + + string constant factoryVersion = "Factory v1"; + string constant poolVersion = "Pool v1"; + + uint256[] internal weights; + + uint256 internal daiIdx; + uint256 internal usdcIdx; + + function setUp() public virtual override { + expectedAddLiquidityBptAmountOut = TOKEN_AMOUNT; + tokenAmountIn = TOKEN_AMOUNT / 4; + isTestSwapFeeEnabled = false; + + BasePoolTest.setUp(); + + (daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc)); + + poolMinSwapFeePercentage = 0.001e16; // 0.001% + poolMaxSwapFeePercentage = 10e16; + } + + function createPoolFactory() internal override returns (address) { + LBPoolFactory factory = new LBPoolFactory( + IVault(address(vault)), + 365 days, + factoryVersion, + poolVersion, + address(router), + permit2 + ); + vm.label(address(factory), "LBPoolFactory"); + + return address(factory); + } + + function createPool() internal override returns (address newPool, bytes memory poolArgs) { + IERC20[] memory sortedTokens = InputHelpers.sortTokens( + [address(dai), address(usdc)].toMemoryArray().asIERC20() + ); + for (uint256 i = 0; i < sortedTokens.length; i++) { + poolTokens.push(sortedTokens[i]); + tokenAmounts.push(TOKEN_AMOUNT); + } + + weights = [uint256(50e16), uint256(50e16)].toMemoryArray(); + + // Allow pools created by `poolFactory` to use poolHooksMock hooks + PoolHooksMock(poolHooksContract).allowFactory(poolFactory); + + string memory name = "LB Pool"; + string memory symbol = "LB_POOL"; + + poolArgs = abi.encode( + WeightedPool.NewPoolParams({ + name: name, + symbol: symbol, + numTokens: sortedTokens.length, + normalizedWeights: weights, + version: poolVersion + }), + vault, + bob, + true, + address(router) + ); + + newPool = LBPoolFactory(poolFactory).create( + name, + symbol, + vault.buildTokenConfig(sortedTokens), + weights, + DEFAULT_SWAP_FEE, + bob, + true, + ZERO_BYTES32 + ); + } + + function testGetTrustedRouter() public view { + assertEq(LBPool(pool).getTrustedRouter(), address(router), "Wrong trusted router"); + } + + function testInitialize() public view override { + (, , uint256[] memory balances, ) = vault.getPoolTokenInfo(address(pool)); + + for (uint256 i = 0; i < poolTokens.length; ++i) { + // Tokens are transferred from bob (lp/owner) + assertEq( + defaultAccountBalance() - poolTokens[i].balanceOf(bob), + tokenAmounts[i], + string.concat("LP: Wrong balance for ", Strings.toString(i)) + ); + + // Tokens are stored in the Vault + assertEq( + poolTokens[i].balanceOf(address(vault)), + tokenAmounts[i], + string.concat("LP: Vault balance for ", Strings.toString(i)) + ); + + // Tokens are deposited to the pool + assertEq( + balances[i], + tokenAmounts[i], + string.concat("Pool: Wrong token balance for ", Strings.toString(i)) + ); + } + + // should mint correct amount of BPT poolTokens + // Account for the precision loss + assertApproxEqAbs(IERC20(pool).balanceOf(bob), bptAmountOut, DELTA, "LP: Wrong bptAmountOut"); + assertApproxEqAbs(bptAmountOut, expectedAddLiquidityBptAmountOut, DELTA, "Wrong bptAmountOut"); + } + + function initPool() internal override { + vm.startPrank(bob); + bptAmountOut = _initPool( + pool, + tokenAmounts, + // Account for the precision loss + expectedAddLiquidityBptAmountOut - DELTA + ); + vm.stopPrank(); + } + + // overriding b/c bob needs to be the LP and has contributed double the "normal" amount of tokens + function testAddLiquidity() public override { + uint256 oldBptAmount = IERC20(pool).balanceOf(bob); + vm.prank(bob); + bptAmountOut = router.addLiquidityUnbalanced(pool, tokenAmounts, tokenAmountIn - DELTA, false, bytes("")); + + (, , uint256[] memory balances, ) = vault.getPoolTokenInfo(address(pool)); + + for (uint256 i = 0; i < poolTokens.length; ++i) { + // Tokens are transferred from Bob + assertEq( + defaultAccountBalance() - poolTokens[i].balanceOf(bob), + tokenAmounts[i] * 2, // x2 because bob (as owner) did init join and subsequent join + string.concat("LP: Wrong token balance for ", Strings.toString(i)) + ); + + // Tokens are stored in the Vault + assertEq( + poolTokens[i].balanceOf(address(vault)), + tokenAmounts[i] * 2, + string.concat("Vault: Wrong token balance for ", Strings.toString(i)) + ); + + assertEq( + balances[i], + tokenAmounts[i] * 2, + string.concat("Pool: Wrong token balance for ", Strings.toString(i)) + ); + } + + uint256 newBptAmount = IERC20(pool).balanceOf(bob); + + // should mint correct amount of BPT poolTokens + assertApproxEqAbs(newBptAmount - oldBptAmount, bptAmountOut, DELTA, "LP: Wrong bptAmountOut"); + assertApproxEqAbs(bptAmountOut, expectedAddLiquidityBptAmountOut, DELTA, "Wrong bptAmountOut"); + } + + // overriding b/c bob has swap fee authority, not governance + // TODO: why does this test need to change swap fee anyway? + function testAddLiquidityUnbalanced() public override { + vm.prank(bob); + vault.setStaticSwapFeePercentage(pool, 10e16); + + uint256[] memory amountsIn = tokenAmounts; + amountsIn[0] = amountsIn[0].mulDown(IBasePool(pool).getMaximumInvariantRatio()); + vm.prank(bob); + + router.addLiquidityUnbalanced(pool, amountsIn, 0, false, bytes("")); + } + + function testRemoveLiquidity() public override { + vm.startPrank(bob); + uint256 oldBptAmount = IERC20(pool).balanceOf(bob); + router.addLiquidityUnbalanced(pool, tokenAmounts, tokenAmountIn - DELTA, false, bytes("")); + uint256 newBptAmount = IERC20(pool).balanceOf(bob); + + IERC20(pool).approve(address(vault), MAX_UINT256); + + uint256 bptAmountIn = newBptAmount - oldBptAmount; + + uint256[] memory minAmountsOut = new uint256[](poolTokens.length); + for (uint256 i = 0; i < poolTokens.length; ++i) { + minAmountsOut[i] = less(tokenAmounts[i], 1e4); + } + + uint256[] memory amountsOut = router.removeLiquidityProportional( + pool, + bptAmountIn, + minAmountsOut, + false, + bytes("") + ); + + vm.stopPrank(); + + (, , uint256[] memory balances, ) = vault.getPoolTokenInfo(address(pool)); + + for (uint256 i = 0; i < poolTokens.length; ++i) { + // Tokens are transferred to Bob + assertApproxEqAbs( + poolTokens[i].balanceOf(bob) + TOKEN_AMOUNT, // add TOKEN_AMOUNT to account for init join + defaultAccountBalance(), + DELTA, + string.concat("LP: Wrong token balance for ", Strings.toString(i)) + ); + + // Tokens are stored in the Vault + assertApproxEqAbs( + poolTokens[i].balanceOf(address(vault)), + tokenAmounts[i], + DELTA, + string.concat("Vault: Wrong token balance for ", Strings.toString(i)) + ); + + // Tokens are deposited to the pool + assertApproxEqAbs( + balances[i], + tokenAmounts[i], + DELTA, + string.concat("Pool: Wrong token balance for ", Strings.toString(i)) + ); + + // amountsOut are correct + assertApproxEqAbs( + amountsOut[i], + tokenAmounts[i], + DELTA, + string.concat("Wrong token amountOut for ", Strings.toString(i)) + ); + } + + // should return to correct amount of BPT poolTokens + assertEq(IERC20(pool).balanceOf(bob), oldBptAmount, "LP: Wrong BPT balance"); + } + + function testSwap() public override { + if (!isTestSwapFeeEnabled) { + vault.manuallySetSwapFee(pool, 0); + } + + IERC20 tokenIn = poolTokens[tokenIndexIn]; + IERC20 tokenOut = poolTokens[tokenIndexOut]; + + uint256 bobBeforeBalanceTokenOut = tokenOut.balanceOf(bob); + uint256 bobBeforeBalanceTokenIn = tokenIn.balanceOf(bob); + + vm.prank(bob); + uint256 amountCalculated = router.swapSingleTokenExactIn( + pool, + tokenIn, + tokenOut, + tokenAmountIn, + less(tokenAmountOut, 1e3), + MAX_UINT256, + false, + bytes("") + ); + + // Tokens are transferred from Bob + assertEq(tokenOut.balanceOf(bob), bobBeforeBalanceTokenOut + amountCalculated, "LP: Wrong tokenOut balance"); + assertEq(tokenIn.balanceOf(bob), bobBeforeBalanceTokenIn - tokenAmountIn, "LP: Wrong tokenIn balance"); + + // Tokens are stored in the Vault + assertEq( + tokenOut.balanceOf(address(vault)), + tokenAmounts[tokenIndexOut] - amountCalculated, + "Vault: Wrong tokenOut balance" + ); + assertEq( + tokenIn.balanceOf(address(vault)), + tokenAmounts[tokenIndexIn] + tokenAmountIn, + "Vault: Wrong tokenIn balance" + ); + + (, , uint256[] memory balances, ) = vault.getPoolTokenInfo(pool); + + assertEq(balances[tokenIndexIn], tokenAmounts[tokenIndexIn] + tokenAmountIn, "Pool: Wrong tokenIn balance"); + assertEq( + balances[tokenIndexOut], + tokenAmounts[tokenIndexOut] - amountCalculated, + "Pool: Wrong tokenOut balance" + ); + } + + function testOnlyOwnerCanBeLP() public { + uint256[] memory amounts = [TOKEN_AMOUNT, TOKEN_AMOUNT].toMemoryArray(); + + vm.startPrank(bob); + router.addLiquidityUnbalanced(address(pool), amounts, 0, false, ""); + vm.stopPrank(); + + vm.startPrank(alice); + vm.expectRevert(abi.encodeWithSelector(IVaultErrors.BeforeAddLiquidityHookFailed.selector)); + router.addLiquidityUnbalanced(address(pool), amounts, 0, false, ""); + vm.stopPrank(); + } + + function testSwapRestrictions() public { + // Ensure swaps are initially enabled + assertTrue(LBPool(address(pool)).getSwapEnabled(), "Swaps should be enabled initially"); + + // Test swap when enabled + vm.prank(alice); + router.swapSingleTokenExactIn( + address(pool), + IERC20(dai), + IERC20(usdc), + TOKEN_AMOUNT / 10, + 0, + block.timestamp + 1 hours, + false, + "" + ); + + // Disable swaps + vm.prank(bob); + LBPool(address(pool)).setSwapEnabled(false); + + // Verify swaps are disabled + assertFalse(LBPool(address(pool)).getSwapEnabled(), "Swaps should be disabled"); + + // Test swap when disabled + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LBPool.SwapsDisabled.selector)); + router.swapSingleTokenExactIn( + address(pool), + IERC20(dai), + IERC20(usdc), + TOKEN_AMOUNT / 10, + 0, + block.timestamp + 1 hours, + false, + "" + ); + + // Re-enable swaps + vm.prank(bob); + LBPool(address(pool)).setSwapEnabled(true); + + // Verify swaps are re-enabled + assertTrue(LBPool(address(pool)).getSwapEnabled(), "Swaps should be re-enabled"); + + // Test swap after re-enabling + vm.prank(alice); + router.swapSingleTokenExactIn( + address(pool), + IERC20(dai), + IERC20(usdc), + TOKEN_AMOUNT / 10, + 0, + block.timestamp + 1 hours, + false, + "" + ); + } + + function testEnsureNoTimeOverflow() public { + uint256 blockDotTimestampTestStart = block.timestamp; + uint256[] memory endWeights = new uint256[](2); + endWeights[0] = 0.01e18; // 1% + endWeights[1] = 0.99e18; // 99% + + vm.prank(bob); + vm.expectRevert(stdError.arithmeticError); + LBPool(address(pool)).updateWeightsGradually(blockDotTimestampTestStart, type(uint32).max + 1, endWeights); + } + + function testQuerySwapDuringWeightUpdate() public { + // Cache original time to avoid issues from `block.timestamp` during `vm.warp` + uint256 blockDotTimestampTestStart = block.timestamp; + + uint256 testDuration = 1 days; + uint256 weightUpdateStep = 1 hours; + uint256 constantWeightDuration = 6 hours; + uint256 startTime = blockDotTimestampTestStart + constantWeightDuration; + + uint256[] memory endWeights = new uint256[](2); + endWeights[0] = 0.01e18; // 1% + endWeights[1] = 0.99e18; // 99% + + uint256 amountIn = TOKEN_AMOUNT / 10; + uint256 constantWeightSteps = constantWeightDuration / weightUpdateStep; + uint256 weightUpdateSteps = testDuration / weightUpdateStep; + + // Start the gradual weight update + vm.prank(bob); + LBPool(address(pool)).updateWeightsGradually(startTime, startTime + testDuration, endWeights); + + uint256 prevAmountOut; + uint256 amountOut; + + // Perform query swaps before the weight update starts + vm.warp(blockDotTimestampTestStart); + prevAmountOut = _executeAndUndoSwap(amountIn); + for (uint256 i = 1; i < constantWeightSteps; i++) { + uint256 currTime = blockDotTimestampTestStart + i * weightUpdateStep; + vm.warp(currTime); + amountOut = _executeAndUndoSwap(amountIn); + assertEq(amountOut, prevAmountOut, "Amount out should remain constant before weight update"); + prevAmountOut = amountOut; + } + + // Perform query swaps during the weight update + vm.warp(startTime); + prevAmountOut = _executeAndUndoSwap(amountIn); + for (uint256 i = 1; i <= weightUpdateSteps; i++) { + vm.warp(startTime + i * weightUpdateStep); + amountOut = _executeAndUndoSwap(amountIn); + assertTrue(amountOut > prevAmountOut, "Amount out should increase during weight update"); + prevAmountOut = amountOut; + } + + // Perform query swaps after the weight update ends + vm.warp(startTime + testDuration); + prevAmountOut = _executeAndUndoSwap(amountIn); + for (uint256 i = 1; i < constantWeightSteps; i++) { + vm.warp(startTime + testDuration + i * weightUpdateStep); + amountOut = _executeAndUndoSwap(amountIn); + assertEq(amountOut, prevAmountOut, "Amount out should remain constant after weight update"); + prevAmountOut = amountOut; + } + } + + function testGetGradualWeightUpdateParams() public { + uint256 startTime = block.timestamp + 1 days; + uint256 endTime = startTime + 7 days; + uint256[] memory endWeights = new uint256[](2); + endWeights[0] = 0.2e18; // 20% + endWeights[1] = 0.8e18; // 80% + + vm.prank(bob); + LBPool(address(pool)).updateWeightsGradually(startTime, endTime, endWeights); + + (uint256 returnedStartTime, uint256 returnedEndTime, uint256[] memory returnedEndWeights) = LBPool( + address(pool) + ).getGradualWeightUpdateParams(); + + assertEq(returnedStartTime, startTime, "Start time should match"); + assertEq(returnedEndTime, endTime, "End time should match"); + assertEq(returnedEndWeights.length, endWeights.length, "End weights length should match"); + for (uint256 i = 0; i < endWeights.length; i++) { + assertEq(returnedEndWeights[i], endWeights[i], "End weight should match"); + } + } + + function testUpdateWeightsGraduallyMinWeightRevert() public { + uint256 startTime = block.timestamp + 1 days; + uint256 endTime = startTime + 7 days; + uint256[] memory endWeights = new uint256[](2); + endWeights[0] = 0.0001e18; // 0.01% + endWeights[1] = 0.9999e18; // 99.99% + + vm.prank(bob); + vm.expectRevert(WeightedPool.MinWeight.selector); + LBPool(address(pool)).updateWeightsGradually(startTime, endTime, endWeights); + } + + function testUpdateWeightsGraduallyNormalizedWeightInvariantRevert() public { + uint256 startTime = block.timestamp + 1 days; + uint256 endTime = startTime + 7 days; + uint256[] memory endWeights = new uint256[](2); + endWeights[0] = 0.6e18; // 60% + endWeights[1] = 0.5e18; // 50% + + vm.prank(bob); + vm.expectRevert(WeightedPool.NormalizedWeightInvariant.selector); + LBPool(address(pool)).updateWeightsGradually(startTime, endTime, endWeights); + } + + function testAddLiquidityRouterNotTrusted() public { + RouterMock mockRouter = new RouterMock(IVault(address(vault)), weth, permit2); + + uint256[] memory amounts = [TOKEN_AMOUNT, TOKEN_AMOUNT].toMemoryArray(); + + vm.startPrank(bob); + vm.expectRevert(abi.encodeWithSelector(LBPool.RouterNotTrusted.selector)); + mockRouter.addLiquidityUnbalanced(address(pool), amounts, 0, false, ""); + vm.stopPrank(); + } + + function testInvalidTokenCount() public { + IERC20[] memory sortedTokens1 = InputHelpers.sortTokens([address(dai)].toMemoryArray().asIERC20()); + IERC20[] memory sortedTokens3 = InputHelpers.sortTokens( + [address(dai), address(usdc), address(weth)].toMemoryArray().asIERC20() + ); + + TokenConfig[] memory tokenConfig1 = vault.buildTokenConfig(sortedTokens1); + TokenConfig[] memory tokenConfig3 = vault.buildTokenConfig(sortedTokens3); + + // Attempt to create a pool with 1 token + vm.expectRevert(InputHelpers.InputLengthMismatch.selector); + LBPoolFactory(poolFactory).create( + "Invalid Pool 1", + "IP1", + tokenConfig1, + [uint256(1e18)].toMemoryArray(), + DEFAULT_SWAP_FEE, + bob, + true, + ZERO_BYTES32 + ); + + // Attempt to create a pool with 3 tokens + vm.expectRevert(InputHelpers.InputLengthMismatch.selector); + LBPoolFactory(poolFactory).create( + "Invalid Pool 3", + "IP3", + tokenConfig3, + [uint256(0.3e18), uint256(0.3e18), uint256(0.4e18)].toMemoryArray(), + DEFAULT_SWAP_FEE, + bob, + true, + ZERO_BYTES32 + ); + } + + function testMismatchedWeightsAndTokens() public { + TokenConfig[] memory tokenConfig = vault.buildTokenConfig(poolTokens); + + vm.expectRevert(InputHelpers.InputLengthMismatch.selector); + LBPoolFactory(poolFactory).create( + "Mismatched Pool", + "MP", + tokenConfig, + [uint256(1e18)].toMemoryArray(), + DEFAULT_SWAP_FEE, + bob, + true, + ZERO_BYTES32 + ); + } + + function testInitializedWithSwapsDisabled() public { + LBPool swapsDisabledPool = LBPool( + LBPoolFactory(poolFactory).create( + "Swaps Disabled Pool", + "SDP", + vault.buildTokenConfig(poolTokens), + weights, + DEFAULT_SWAP_FEE, + bob, + false, // swapEnabledOnStart set to false + keccak256(abi.encodePacked(block.timestamp)) // generate pseudorandom salt to avoid collision + ) + ); + + assertFalse(swapsDisabledPool.getSwapEnabled(), "Swaps should be disabled on initialization"); + + // Initialize to make swapping (or at least trying) possible + vm.startPrank(bob); + bptAmountOut = _initPool( + address(swapsDisabledPool), + tokenAmounts, + // Account for the precision loss + expectedAddLiquidityBptAmountOut - DELTA + ); + vm.stopPrank(); + + vm.startPrank(alice); + vm.expectRevert(abi.encodeWithSelector(LBPool.SwapsDisabled.selector)); + router.swapSingleTokenExactIn( + address(swapsDisabledPool), + IERC20(dai), + IERC20(usdc), + TOKEN_AMOUNT / 10, + 0, + block.timestamp + 1 hours, + false, + "" + ); + vm.stopPrank(); + } + + function testUpdateWeightsGraduallyMismatchedEndWeightsTooFew() public { + uint256 startTime = block.timestamp + 1 days; + uint256 endTime = startTime + 7 days; + uint256[] memory endWeights = new uint256[](1); // Too few end weights + endWeights[0] = 1e18; + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(InputHelpers.InputLengthMismatch.selector)); + LBPool(address(pool)).updateWeightsGradually(startTime, endTime, endWeights); + } + + function testUpdateWeightsGraduallyMismatchedEndWeightsTooMany() public { + uint256 startTime = block.timestamp + 1 days; + uint256 endTime = startTime + 7 days; + uint256[] memory endWeights = new uint256[](3); // Too many end weights + endWeights[0] = 0.3e18; + endWeights[1] = 0.3e18; + endWeights[2] = 0.4e18; + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(InputHelpers.InputLengthMismatch.selector)); + LBPool(address(pool)).updateWeightsGradually(startTime, endTime, endWeights); + } + + function testNonOwnerCannotUpdateWeights() public { + uint256 startTime = block.timestamp + 1 days; + uint256 endTime = startTime + 7 days; + uint256[] memory endWeights = new uint256[](2); + endWeights[0] = 0.7e18; + endWeights[1] = 0.3e18; + + vm.prank(alice); // Non-owner + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(alice))); + LBPool(address(pool)).updateWeightsGradually(startTime, endTime, endWeights); + } + + function testOnSwapInvalidTokenIndex() public { + vm.prank(address(vault)); + + PoolSwapParams memory request = PoolSwapParams({ + kind: SwapKind.EXACT_IN, + amountGivenScaled18: 1e18, + balancesScaled18: new uint256[](3), // add an extra (non-existent) value to give the bad index a balance + indexIn: 2, // Invalid token index + indexOut: 0, + router: address(router), + userData: "" + }); + + vm.expectRevert(IVaultErrors.InvalidToken.selector); + LBPool(pool).onSwap(request); + } + + function _executeAndUndoSwap(uint256 amountIn) internal returns (uint256) { + // Create a storage checkpoint + uint256 snapshot = vm.snapshot(); + + try this.executeSwap(amountIn) returns (uint256 amountOut) { + // Revert to the snapshot to undo the swap + vm.revertTo(snapshot); + return amountOut; + } catch Error(string memory reason) { + vm.revertTo(snapshot); + revert(reason); + } catch { + vm.revertTo(snapshot); + revert("Low level error during swap"); + } + } + + function executeSwap(uint256 amountIn) external returns (uint256) { + // Ensure this contract has enough tokens and allowance + deal(address(dai), address(bob), amountIn); + vm.prank(bob); + IERC20(dai).approve(address(router), amountIn); + + // Perform the actual swap + vm.prank(bob); + return + router.swapSingleTokenExactIn( + address(pool), + IERC20(dai), + IERC20(usdc), + amountIn, + 0, // minAmountOut: Set to 0 or a minimum amount if desired + block.timestamp, // deadline = now to ensure it won't timeout + false, // wethIsEth: Set to false assuming DAI and USDC are not ETH + "" // userData: Empty bytes as no additional data is needed + ); + } +} diff --git a/pkg/pool-weighted/test/foundry/LBPoolFactory.t.sol b/pkg/pool-weighted/test/foundry/LBPoolFactory.t.sol new file mode 100644 index 000000000..16045a75f --- /dev/null +++ b/pkg/pool-weighted/test/foundry/LBPoolFactory.t.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; +import { PoolRoleAccounts } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol"; +import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol"; +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; + +import { LBPoolFactory } from "../../contracts/lbp/LBPoolFactory.sol"; + +contract LBPoolFactoryTest is BaseVaultTest { + using CastingHelpers for address[]; + using ArrayHelpers for *; + + uint64 public constant swapFee = 1e16; //1% + + LBPoolFactory internal lbPoolFactory; + + string public constant poolVersion = "Pool v1"; + + function setUp() public override { + super.setUp(); + + lbPoolFactory = new LBPoolFactory( + IVault(address(vault)), + 365 days, + "Factory v1", + poolVersion, + address(router), + permit2 + ); + vm.label(address(lbPoolFactory), "LB pool factory"); + } + + function testGetTrustedRouter() public view { + assertEq(lbPoolFactory.getTrustedRouter(), address(router), "Wrong trusted router"); + } + + function testFactoryPausedState() public view { + uint32 pauseWindowDuration = lbPoolFactory.getPauseWindowDuration(); + assertEq(pauseWindowDuration, 365 days); + } + + function testCreatePool() public { + address lbPool = _deployAndInitializeLBPool(); + + // Verify pool was created and initialized correctly + assertTrue(vault.isPoolRegistered(lbPool), "Pool not registered in the vault"); + } + + function testCreateAndInitializePool() public { + vm.startPrank(bob); + + uint256 snapshotId = vm.snapshot(); + + IERC20[] memory tokens = [address(dai), address(usdc)].toMemoryArray().asIERC20(); + uint256[] memory weights = [uint256(50e16), uint256(50e16)].toMemoryArray(); + uint256[] memory exactAmountsIn = [poolInitAmount, poolInitAmount].toMemoryArray(); + + address expectedLbPoolAddress = lbPoolFactory.create( + "LB Pool", + "LBP", + vault.buildTokenConfig(tokens), + weights, + swapFee, + bob, // owner + true, // swapEnabledOnStart + ZERO_BYTES32 + ); + + vm.revertTo(snapshotId); + + dai.approve(address(lbPoolFactory), poolInitAmount); + usdc.approve(address(lbPoolFactory), poolInitAmount); + + vm.expectCall( + address(router), + abi.encodeCall(IRouter.initialize, (expectedLbPoolAddress, tokens, exactAmountsIn, 0, false, "")) + ); + address lbPool = lbPoolFactory.createAndInitialize( + "LB Pool", + "LBP", + vault.buildTokenConfig(tokens), + weights, + swapFee, + bob, // owner + true, // swapEnabledOnStart + ZERO_BYTES32, + exactAmountsIn + ); + vm.stopPrank(); + + // Verify pool was created and initialized correctly + assertTrue(vault.isPoolRegistered(lbPool), "Pool not registered in the vault"); + assertTrue(vault.isPoolInitialized(lbPool), "Pool not initialized in the vault"); + assertEq(expectedLbPoolAddress, lbPool, "Unexpected pool address"); + + (, , uint256[] memory balancesRaw, ) = vault.getPoolTokenInfo(lbPool); + assertEq(balancesRaw.length, 2, "Unexpected balances raw length"); + assertEq(balancesRaw[0], exactAmountsIn[0], "Unexpected balances raw [0]"); + assertEq(balancesRaw[1], exactAmountsIn[1], "Unexpected balances raw [1]"); + } + + function testGetPoolVersion() public view { + assert(keccak256(abi.encodePacked(lbPoolFactory.getPoolVersion())) == keccak256(abi.encodePacked(poolVersion))); + } + + function testDonationNotAllowed() public { + address lbPool = _deployAndInitializeLBPool(); + + // Try to donate to the pool + vm.startPrank(bob); + vm.expectRevert(IVaultErrors.DoesNotSupportDonation.selector); + router.donate(lbPool, [poolInitAmount, poolInitAmount].toMemoryArray(), false, bytes("")); + vm.stopPrank(); + } + + function _deployAndInitializeLBPool() private returns (address) { + IERC20[] memory tokens = [address(dai), address(usdc)].toMemoryArray().asIERC20(); + uint256[] memory weights = [uint256(50e16), uint256(50e16)].toMemoryArray(); + + address lbPool = lbPoolFactory.create( + "LB Pool", + "LBP", + vault.buildTokenConfig(tokens), + weights, + swapFee, + bob, // owner + true, // swapEnabledOnStart + ZERO_BYTES32 + ); + + // Initialize pool. + vm.prank(bob); // Owner initializes the pool + router.initialize(lbPool, tokens, [poolInitAmount, poolInitAmount].toMemoryArray(), 0, false, bytes("")); + + return lbPool; + } +} diff --git a/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - ERC4626 - BatchRouter] swapExactOut - with buffer liquidity - warm slots b/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - ERC4626 - BatchRouter] swapExactOut - with buffer liquidity - warm slots index 5e97e74e1..74805369f 100644 --- a/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - ERC4626 - BatchRouter] swapExactOut - with buffer liquidity - warm slots +++ b/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - ERC4626 - BatchRouter] swapExactOut - with buffer liquidity - warm slots @@ -1 +1 @@ -244.7k \ No newline at end of file +244.8k \ No newline at end of file diff --git a/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - Standard] initialize with ETH b/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - Standard] initialize with ETH index 13e58083b..6afcf8ec2 100644 --- a/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - Standard] initialize with ETH +++ b/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - Standard] initialize with ETH @@ -1 +1 @@ -348.8k \ No newline at end of file +348.9k \ No newline at end of file diff --git a/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - WithRate - BatchRouter] remove liquidity using swapExactIn - warm slots b/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - WithRate - BatchRouter] remove liquidity using swapExactIn - warm slots index 04c51970a..a5dc1053f 100644 --- a/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - WithRate - BatchRouter] remove liquidity using swapExactIn - warm slots +++ b/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - WithRate - BatchRouter] remove liquidity using swapExactIn - warm slots @@ -1 +1 @@ -198.2k \ No newline at end of file +198.3k \ No newline at end of file diff --git a/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - WithRate] remove liquidity single token exact in - warm slots b/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - WithRate] remove liquidity single token exact in - warm slots index d3fa14d41..61e5d8518 100644 --- a/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - WithRate] remove liquidity single token exact in - warm slots +++ b/pkg/pool-weighted/test/gas/.hardhat-snapshots/[WeightedPool - WithRate] remove liquidity single token exact in - warm slots @@ -1 +1 @@ -174.0k \ No newline at end of file +174.1k \ No newline at end of file diff --git a/pkg/vault/test/foundry/utils/BasePoolTest.sol b/pkg/vault/test/foundry/utils/BasePoolTest.sol index bb5ae9dc4..57e76d8d3 100644 --- a/pkg/vault/test/foundry/utils/BasePoolTest.sol +++ b/pkg/vault/test/foundry/utils/BasePoolTest.sol @@ -7,6 +7,7 @@ import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IRateProvider } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IRateProvider.sol"; import { IBasePoolFactory } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePoolFactory.sol"; +import { PoolRoleAccounts } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultAdmin.sol"; @@ -55,7 +56,7 @@ abstract contract BasePoolTest is BaseVaultTest { assertEq(pool, calculatedPoolAddress, "Pool address mismatch"); } - function testPoolPausedState() public view { + function testPoolPausedState() public view virtual { (bool paused, uint256 pauseWindow, uint256 bufferPeriod, address pauseManager) = vault.getPoolPausedState(pool); assertFalse(paused, "Vault should not be paused initially"); @@ -64,7 +65,7 @@ abstract contract BasePoolTest is BaseVaultTest { assertEq(pauseManager, address(0), "Pause manager should be 0"); } - function testInitialize() public view { + function testInitialize() public view virtual { (, , uint256[] memory balances, ) = vault.getPoolTokenInfo(address(pool)); for (uint256 i = 0; i < poolTokens.length; ++i) { @@ -95,7 +96,7 @@ abstract contract BasePoolTest is BaseVaultTest { assertApproxEqAbs(bptAmountOut, expectedAddLiquidityBptAmountOut, DELTA, "Wrong bptAmountOut"); } - function testAddLiquidity() public { + function testAddLiquidity() public virtual { vm.prank(bob); bptAmountOut = router.addLiquidityUnbalanced(pool, tokenAmounts, tokenAmountIn - DELTA, false, bytes("")); @@ -128,7 +129,7 @@ abstract contract BasePoolTest is BaseVaultTest { assertApproxEqAbs(bptAmountOut, expectedAddLiquidityBptAmountOut, DELTA, "Wrong bptAmountOut"); } - function testRemoveLiquidity() public { + function testRemoveLiquidity() public virtual { vm.startPrank(bob); router.addLiquidityUnbalanced(pool, tokenAmounts, tokenAmountIn - DELTA, false, bytes("")); @@ -193,7 +194,7 @@ abstract contract BasePoolTest is BaseVaultTest { assertEq(bobBptBalance, bptAmountIn, "LP: Wrong bptAmountIn"); } - function testSwap() public { + function testSwap() public virtual { if (!isTestSwapFeeEnabled) { vault.manuallySetSwapFee(pool, 0); } @@ -248,22 +249,20 @@ abstract contract BasePoolTest is BaseVaultTest { } function testSetSwapFeeTooLow() public { - authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), alice); - vm.prank(alice); + vm.prank(_getSwapFeeAdmin()); vm.expectRevert(IVaultErrors.SwapFeePercentageTooLow.selector); vault.setStaticSwapFeePercentage(pool, poolMinSwapFeePercentage - 1); } function testSetSwapFeeTooHigh() public { - authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), alice); - vm.prank(alice); + vm.prank(_getSwapFeeAdmin()); vm.expectRevert(abi.encodeWithSelector(IVaultErrors.SwapFeePercentageTooHigh.selector)); vault.setStaticSwapFeePercentage(pool, poolMaxSwapFeePercentage + 1); } - function testAddLiquidityUnbalanced() public { + function testAddLiquidityUnbalanced() public virtual { authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), alice); vm.prank(alice); vault.setStaticSwapFeePercentage(pool, 10e16); @@ -295,4 +294,15 @@ abstract contract BasePoolTest is BaseVaultTest { function _less(uint256 amount, uint256 base) private pure returns (uint256) { return (amount * (base - 1)) / base; } + + function _getSwapFeeAdmin() internal returns (address) { + PoolRoleAccounts memory roleAccounts = vault.getPoolRoleAccounts(pool); + address swapFeeManager = roleAccounts.swapFeeManager; + + if (swapFeeManager == address(0)) { + swapFeeManager = alice; + authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), swapFeeManager); + } + return swapFeeManager; + } }