From beeadbb2f194f399739c8c80ae1fe0ec8388e3a5 Mon Sep 17 00:00:00 2001 From: dcpp Date: Thu, 28 Aug 2025 12:31:16 +0300 Subject: [PATCH 01/13] pool-range pkg: initial implementation --- pkg/pool-range/.solhintignore | 1 + pkg/pool-range/README.md | 70 +++ pkg/pool-range/contracts/BaseRangePool.sol | 483 ++++++++++++++++++ pkg/pool-range/contracts/RangeMath.sol | 83 +++ pkg/pool-range/contracts/RangePool.sol | 421 +++++++++++++++ .../contracts/RangePoolProtocolFees.sol | 360 +++++++++++++ pkg/pool-range/foundry.toml | 1 + pkg/pool-range/hardhat.config.ts | 45 ++ pkg/pool-range/package.json | 72 +++ pkg/pool-range/tsconfig.json | 6 + yarn.lock | 79 +++ 11 files changed, 1621 insertions(+) create mode 100644 pkg/pool-range/.solhintignore create mode 100644 pkg/pool-range/README.md create mode 100644 pkg/pool-range/contracts/BaseRangePool.sol create mode 100644 pkg/pool-range/contracts/RangeMath.sol create mode 100644 pkg/pool-range/contracts/RangePool.sol create mode 100644 pkg/pool-range/contracts/RangePoolProtocolFees.sol create mode 120000 pkg/pool-range/foundry.toml create mode 100644 pkg/pool-range/hardhat.config.ts create mode 100644 pkg/pool-range/package.json create mode 100644 pkg/pool-range/tsconfig.json diff --git a/pkg/pool-range/.solhintignore b/pkg/pool-range/.solhintignore new file mode 100644 index 000000000..11d11fbae --- /dev/null +++ b/pkg/pool-range/.solhintignore @@ -0,0 +1 @@ +contracts/test/ diff --git a/pkg/pool-range/README.md b/pkg/pool-range/README.md new file mode 100644 index 000000000..6199501d7 --- /dev/null +++ b/pkg/pool-range/README.md @@ -0,0 +1,70 @@ +# Balancer + +# Balancer V2 Weighted Pools + +[![NPM Package](https://img.shields.io/npm/v/@balancer-labs/v2-pool-weighted.svg)](https://www.npmjs.org/package/@balancer-labs/v2-pool-weighted) + +This package contains the source code for Balancer V2 Weighted Pools, that is, Pools that swap tokens by enforcing a Constant Weighted Product invariant. + +The pool currently in existence is [`WeightedPool`](./contracts/WeightedPool.sol) (basic twenty token version). + +There are subdirectories for common variants, which automatically updates some of their attributes to support more complex use cases. Examples are [`LiquidityBootstrappingPool`](./contracts/lbp/LiquidityBootstrappingPool.sol) for auction-like mechanisms, and [`ManagedPool`](./contracts/managed/ManagedPool.sol) for managed portfolios. + +The `lib` directory contains internal and external common libraries, such as [`CircuitBreakerLib`](./contracts/lib/CircuitBreakerLib.sol). + +| :warning: | Managed Pools are still undergoing development and may contain bugs and/or change significantly. | +| --------- | :-------------------------------------------------------------------------------------------------- | + +Another useful contract is [`WeightedMath`](./contracts/WeightedMath.sol), which implements the low level calculations required for swaps, joins, exits and price calculations. + +## Overview + +### Installation + +```console +$ npm install @balancer-labs/v2-pool-weighted +``` + +### Usage + +This package can be used in multiple ways, including interacting with already deployed Pools, performing local testing, or even creating new Pool types that also use the Constant Weighted Product invariant. + +To get the address of deployed contracts in both mainnet and various test networks, see [`balancer-deployments` repository](https://github.com/balancer/balancer-deployments). + +Sample Weighted Pool that computes weights dynamically on every swap, join and exit: + +```solidity +pragma solidity ^0.7.0; + +import '@balancer-labs/v2-pool-weighted/contracts/BaseWeightedPool.sol'; + +contract DynamicWeightedPool is BaseWeightedPool { + uint256 private immutable _creationTime; + + constructor() { + _creationTime = block.timestamp; + } + + function _getNormalizedWeightsAndMaxWeightIndex() internal view override returns (uint256[] memory) { + uint256[] memory weights = new uint256[](2); + + // Change weights from 50-50 to 30-70 one month after deployment + if (block.timestamp < (_creationTime + 1 month)) { + weights[0] = 0.5e18; + weights[1] = 0.5e18; + } else { + weights[0] = 0.3e18; + weights[1] = 0.7e18; + } + + return (weights, 1); + } + + ... +} + +``` + +## Licensing + +[GNU General Public License Version 3 (GPL v3)](../../LICENSE). diff --git a/pkg/pool-range/contracts/BaseRangePool.sol b/pkg/pool-range/contracts/BaseRangePool.sol new file mode 100644 index 000000000..e55e59247 --- /dev/null +++ b/pkg/pool-range/contracts/BaseRangePool.sol @@ -0,0 +1,483 @@ +// 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 . + +pragma solidity ^0.7.0; +pragma experimental ABIEncoderV2; + +import "@balancer-labs/v2-interfaces/contracts/pool-weighted/WeightedPoolUserData.sol"; + +import "@balancer-labs/v2-solidity-utils/contracts/math/FixedPoint.sol"; +import "@balancer-labs/v2-solidity-utils/contracts/helpers/InputHelpers.sol"; + +import "@balancer-labs/v2-pool-utils/contracts/BaseGeneralPool.sol"; +import "@balancer-labs/v2-pool-utils/contracts/lib/BasePoolMath.sol"; + +import "./RangeMath.sol"; + +/** + * @dev Base class for RangePools containing swap, join and exit logic, but leaving storage and management of + * the weights to subclasses. Derived contracts can choose to make weights immutable, mutable, or even dynamic + * based on local or external logic. + */ +abstract contract BaseRangePool is BaseGeneralPool { + using FixedPoint for uint256; + using BasePoolUserData for bytes; + using WeightedPoolUserData for bytes; + + constructor( + IVault vault, + string memory name, + string memory symbol, + IERC20[] memory tokens, + address[] memory assetManagers, + uint256 swapFeePercentage, + uint256 pauseWindowDuration, + uint256 bufferPeriodDuration, + address owner, + bool mutableTokens + ) + BasePool( + vault, + // Given BaseMinimalSwapInfoPool supports both of these specializations, and this Pool never registers + // or deregisters any tokens after construction, picking Two Token when the Pool only has two tokens is free + // gas savings. + // If the pool is expected to be able register new tokens in future, we must choose MINIMAL_SWAP_INFO + // as clearly the TWO_TOKEN specification doesn't support adding extra tokens in future. + tokens.length == 2 && !mutableTokens + ? IVault.PoolSpecialization.TWO_TOKEN + : IVault.PoolSpecialization.MINIMAL_SWAP_INFO, + name, + symbol, + tokens, + assetManagers, + swapFeePercentage, + pauseWindowDuration, + bufferPeriodDuration, + owner + ) + { + // solhint-disable-previous-line no-empty-blocks + } + + // Virtual functions + + /** + * @dev Returns the index of `token`. + */ + function _getTokenIndex(IERC20 token) internal view virtual returns (uint256); + + /** + * @dev Returns the normalized weight of `token`. Weights are fixed point numbers that sum to FixedPoint.ONE. + */ + function _getNormalizedWeight(IERC20 token) internal view virtual returns (uint256); + + /** + * @dev Returns all normalized weights, in the same order as the Pool's tokens. + */ + function _getNormalizedWeights() internal view virtual returns (uint256[] memory); + + /** + * @dev Returns the current value of the invariant. + * **IMPORTANT NOTE**: calling this function within a Vault context (i.e. in the middle of a join or an exit) is + * potentially unsafe, since the returned value is manipulable. It is up to the caller to ensure safety. + * + * Calculating the invariant requires the state of the pool to be in sync with the state of the Vault. + * That condition may not be true in the middle of a join or an exit. + * + * To call this function safely, attempt to trigger the reentrancy guard in the Vault by calling a non-reentrant + * function before calling `getInvariant`. That will make the transaction revert in an unsafe context. + * (See `VaultReentrancyLib.ensureNotInVaultContext` in pool-utils.) + * + * See https://forum.balancer.fi/t/reentrancy-vulnerability-scope-expanded/4345 for reference. + */ + function getInvariant() public view returns (uint256) { + (, uint256[] memory balances, ) = getVault().getPoolTokens(getPoolId()); + + // Since the Pool hooks always work with upscaled balances, we manually + // upscale here for consistency + _upscaleArray(balances, _scalingFactors()); + + uint256[] memory normalizedWeights = _getNormalizedWeights(); + return WeightedMath._calculateInvariant(normalizedWeights, balances); + } + + function getNormalizedWeights() external view returns (uint256[] memory) { + return _getNormalizedWeights(); + } + + // Base Pool handlers + + // Swap + + function _onSwapGivenIn( + SwapRequest memory swapRequest, + uint256[] memory factBalances, + uint256 virtualBalanceIn, + uint256 virtualBalanceOut + ) internal virtual override returns (uint256) { + uint256 tokenOutIdx = _getTokenIndex(swapRequest.tokenOut); + _require(tokenOutIdx < factBalances.length, Errors.OUT_OF_BOUNDS); + return + RangeMath._calcOutGivenIn( + virtualBalanceIn, + _getNormalizedWeight(swapRequest.tokenIn), + virtualBalanceOut, + _getNormalizedWeight(swapRequest.tokenOut), + swapRequest.amount, + factBalances[tokenOutIdx] + ); + } + + function _onSwapGivenOut( + SwapRequest memory swapRequest, + uint256[] memory factBalances, + uint256 virtualBalanceIn, + uint256 virtualBalanceOut + ) internal virtual override returns (uint256) { + uint256 tokenOutIdx = _getTokenIndex(swapRequest.tokenOut); + _require(tokenOutIdx < factBalances.length, Errors.OUT_OF_BOUNDS); + _require(factBalances[tokenOutIdx] >= swapRequest.amount, Errors.INSUFFICIENT_BALANCE); + return + WeightedMath._calcInGivenOut( + virtualBalanceIn, + _getNormalizedWeight(swapRequest.tokenIn), + virtualBalanceOut, + _getNormalizedWeight(swapRequest.tokenOut), + swapRequest.amount + ); + } + + /** + * @dev Called before any join or exit operation. Returns the Pool's total supply by default, but derived contracts + * may choose to add custom behavior at these steps. This often has to do with protocol fee processing. + */ + function _beforeJoinExit(uint256[] memory preBalances, uint256[] memory normalizedWeights) + internal + virtual + returns (uint256, uint256) + { + return (totalSupply(), WeightedMath._calculateInvariant(normalizedWeights, preBalances)); + } + + /** + * @dev Called after any regular join or exit operation. Empty by default, but derived contracts + * may choose to add custom behavior at these steps. This often has to do with protocol fee processing. + * + * If performing a join operation, balanceDeltas are the amounts in: otherwise they are the amounts out. + * + * This function is free to mutate the `preBalances` array. + */ + function _afterJoinExit( + uint256 preJoinExitInvariant, + uint256[] memory preBalances, + uint256[] memory balanceDeltas, + uint256[] memory normalizedWeights, + uint256 preJoinExitSupply, + uint256 postJoinExitSupply + ) internal virtual { + // solhint-disable-previous-line no-empty-blocks + } + + // Derived contracts may call this to update state after a join or exit. + function _updatePostJoinExit(uint256 postJoinExitInvariant) internal virtual { + // solhint-disable-previous-line no-empty-blocks + } + + // Initialize + + function _onInitializePool( + bytes32, + address, + address, + uint256[] memory scalingFactors, + bytes memory userData + ) internal virtual override returns (uint256, uint256[] memory) { + WeightedPoolUserData.JoinKind kind = userData.joinKind(); + _require(kind == WeightedPoolUserData.JoinKind.INIT, Errors.UNINITIALIZED); + + uint256[] memory amountsIn = userData.initialAmountsIn(); + InputHelpers.ensureInputLengthMatch(amountsIn.length, scalingFactors.length); + _upscaleArray(amountsIn, scalingFactors); + + uint256[] memory normalizedWeights = _getNormalizedWeights(); + uint256 invariantAfterJoin = WeightedMath._calculateInvariant(normalizedWeights, amountsIn); + + // Set the initial BPT to the value of the invariant times the number of tokens. This makes BPT supply more + // consistent in Pools with similar compositions but different number of tokens. + uint256 bptAmountOut = Math.mul(invariantAfterJoin, amountsIn.length); + + // Initialization is still a join, so we need to do post-join work. Since we are not paying protocol fees, + // and all we need to do is update the invariant, call `_updatePostJoinExit` here instead of `_afterJoinExit`. + _updatePostJoinExit(invariantAfterJoin); + + return (bptAmountOut, amountsIn); + } + + // Join + + function _onJoinPool( + bytes32, + address sender, + address, + uint256[] memory balances, + uint256, + uint256, + uint256[] memory scalingFactors, + bytes memory userData + ) internal virtual override returns (uint256, uint256[] memory) { + uint256[] memory normalizedWeights = _getNormalizedWeights(); + + (uint256 preJoinExitSupply, uint256 preJoinExitInvariant) = _beforeJoinExit(balances, normalizedWeights); + + (uint256 bptAmountOut, uint256[] memory amountsIn) = _doJoin( + sender, + balances, + normalizedWeights, + scalingFactors, + preJoinExitSupply, + userData + ); + + _afterJoinExit( + preJoinExitInvariant, + balances, + amountsIn, + normalizedWeights, + preJoinExitSupply, + preJoinExitSupply.add(bptAmountOut) + ); + + return (bptAmountOut, amountsIn); + } + + /** + * @dev Dispatch code which decodes the provided userdata to perform the specified join type. + * Inheriting contracts may override this function to add additional join types or extra conditions to allow + * or disallow joins under certain circumstances. + */ + function _doJoin( + address, + uint256[] memory balances, + uint256[] memory /*normalizedWeights*/, + uint256[] memory scalingFactors, + uint256 totalSupply, + bytes memory userData + ) internal view virtual returns (uint256, uint256[] memory) { + WeightedPoolUserData.JoinKind kind = userData.joinKind(); + + if (kind == WeightedPoolUserData.JoinKind.EXACT_TOKENS_IN_FOR_BPT_OUT) { + return _joinExactTokensInForBPTOut(balances, scalingFactors, totalSupply, userData); + } else if (kind == WeightedPoolUserData.JoinKind.TOKEN_IN_FOR_EXACT_BPT_OUT) { + return _joinTokenInForExactBPTOut(balances, totalSupply, userData); + } else if (kind == WeightedPoolUserData.JoinKind.ALL_TOKENS_IN_FOR_EXACT_BPT_OUT) { + return _joinAllTokensInForExactBPTOut(balances, totalSupply, userData); + } else { + _revert(Errors.UNHANDLED_JOIN_KIND); + } + } + + function _joinExactTokensInForBPTOut( + uint256[] memory balances, + uint256[] memory scalingFactors, + uint256 totalSupply, + bytes memory userData + ) private pure returns (uint256, uint256[] memory) { + (uint256[] memory amountsIn, uint256 minBPTAmountOut) = userData.exactTokensInForBptOut(); + InputHelpers.ensureInputLengthMatch(balances.length, amountsIn.length); + + _upscaleArray(amountsIn, scalingFactors); + + uint256 bptAmountOut = RangeMath._calcBptOutGivenExactTokensIn( + balances, + amountsIn, + totalSupply + ); + + _require(bptAmountOut >= minBPTAmountOut, Errors.BPT_OUT_MIN_AMOUNT); + + return (bptAmountOut, amountsIn); + } + + function _joinTokenInForExactBPTOut( + uint256[] memory balances, + uint256 totalSupply, + bytes memory userData + ) private pure returns (uint256, uint256[] memory) { + (uint256 bptAmountOut, uint256 tokenIndex) = userData.tokenInForExactBptOut(); + // Note that there is no maximum amountIn parameter: this is handled by `IVault.joinPool`. + + _require(tokenIndex < balances.length, Errors.OUT_OF_BOUNDS); + + uint256 amountIn = RangeMath._calcTokenInGivenExactBptOut( + balances[tokenIndex], + bptAmountOut, + totalSupply + ); + + // We join in a single token, so we initialize amountsIn with zeros + uint256[] memory amountsIn = new uint256[](balances.length); + // And then assign the result to the selected token + amountsIn[tokenIndex] = amountIn; + + return (bptAmountOut, amountsIn); + } + + function _joinAllTokensInForExactBPTOut( + uint256[] memory balances, + uint256 totalSupply, + bytes memory userData + ) private pure returns (uint256, uint256[] memory) { + uint256 bptAmountOut = userData.allTokensInForExactBptOut(); + // Note that there is no maximum amountsIn parameter: this is handled by `IVault.joinPool`. + + uint256[] memory amountsIn = BasePoolMath.computeProportionalAmountsIn(balances, totalSupply, bptAmountOut); + + return (bptAmountOut, amountsIn); + } + + // Exit + + function _onExitPool( + bytes32, + address sender, + address, + uint256[] memory balances, + uint256, + uint256, + uint256[] memory scalingFactors, + bytes memory userData + ) internal virtual override returns (uint256, uint256[] memory) { + uint256[] memory normalizedWeights = _getNormalizedWeights(); + + (uint256 preJoinExitSupply, uint256 preJoinExitInvariant) = _beforeJoinExit(balances, normalizedWeights); + + (uint256 bptAmountIn, uint256[] memory amountsOut) = _doExit( + sender, + balances, + normalizedWeights, + scalingFactors, + preJoinExitSupply, + userData + ); + + _afterJoinExit( + preJoinExitInvariant, + balances, + amountsOut, + normalizedWeights, + preJoinExitSupply, + preJoinExitSupply.sub(bptAmountIn) + ); + + return (bptAmountIn, amountsOut); + } + + /** + * @dev Dispatch code which decodes the provided userdata to perform the specified exit type. + * Inheriting contracts may override this function to add additional exit types or extra conditions to allow + * or disallow exit under certain circumstances. + */ + function _doExit( + address, + uint256[] memory balances, + uint256[] memory normalizedWeights, + uint256[] memory scalingFactors, + uint256 totalSupply, + bytes memory userData + ) internal view virtual returns (uint256, uint256[] memory) { + WeightedPoolUserData.ExitKind kind = userData.exitKind(); + + if (kind == WeightedPoolUserData.ExitKind.EXACT_BPT_IN_FOR_ONE_TOKEN_OUT) { + return _exitExactBPTInForTokenOut(balances, normalizedWeights, totalSupply, userData); + } else if (kind == WeightedPoolUserData.ExitKind.EXACT_BPT_IN_FOR_TOKENS_OUT) { + return _exitExactBPTInForTokensOut(balances, totalSupply, userData); + } else if (kind == WeightedPoolUserData.ExitKind.BPT_IN_FOR_EXACT_TOKENS_OUT) { + return _exitBPTInForExactTokensOut(balances, scalingFactors, totalSupply, userData); + } else { + _revert(Errors.UNHANDLED_EXIT_KIND); + } + } + + function _exitExactBPTInForTokenOut( + uint256[] memory balances, + uint256[] memory normalizedWeights, + uint256 totalSupply, + bytes memory userData + ) private view returns (uint256, uint256[] memory) { + (uint256 bptAmountIn, uint256 tokenIndex) = userData.exactBptInForTokenOut(); + // Note that there is no minimum amountOut parameter: this is handled by `IVault.exitPool`. + + _require(tokenIndex < balances.length, Errors.OUT_OF_BOUNDS); + + uint256 amountOut = WeightedMath._calcTokenOutGivenExactBptIn( + balances[tokenIndex], + normalizedWeights[tokenIndex], + bptAmountIn, + totalSupply, + getSwapFeePercentage() + ); + + // This is an exceptional situation in which the fee is charged on a token out instead of a token in. + // We exit in a single token, so we initialize amountsOut with zeros + uint256[] memory amountsOut = new uint256[](balances.length); + // And then assign the result to the selected token + amountsOut[tokenIndex] = amountOut; + + return (bptAmountIn, amountsOut); + } + + function _exitExactBPTInForTokensOut( + uint256[] memory balances, + uint256 totalSupply, + bytes memory userData + ) private pure returns (uint256, uint256[] memory) { + uint256 bptAmountIn = userData.exactBptInForTokensOut(); + // Note that there is no minimum amountOut parameter: this is handled by `IVault.exitPool`. + + uint256[] memory amountsOut = BasePoolMath.computeProportionalAmountsOut(balances, totalSupply, bptAmountIn); + return (bptAmountIn, amountsOut); + } + + function _exitBPTInForExactTokensOut( + uint256[] memory balances, + uint256[] memory scalingFactors, + uint256 totalSupply, + bytes memory userData + ) private pure returns (uint256, uint256[] memory) { + (uint256[] memory amountsOut, uint256 maxBPTAmountIn) = userData.bptInForExactTokensOut(); + InputHelpers.ensureInputLengthMatch(amountsOut.length, balances.length); + _upscaleArray(amountsOut, scalingFactors); + + // This is an exceptional situation in which the fee is charged on a token out instead of a token in. + uint256 bptAmountIn = RangeMath._calcBptInGivenExactTokensOut( + balances, + amountsOut, + totalSupply + ); + _require(bptAmountIn <= maxBPTAmountIn, Errors.BPT_IN_MAX_AMOUNT); + + return (bptAmountIn, amountsOut); + } + + // Recovery Mode + + function _doRecoveryModeExit( + uint256[] memory balances, + uint256 totalSupply, + bytes memory userData + ) internal pure override returns (uint256 bptAmountIn, uint256[] memory amountsOut) { + bptAmountIn = userData.recoveryModeExit(); + amountsOut = BasePoolMath.computeProportionalAmountsOut(balances, totalSupply, bptAmountIn); + } +} diff --git a/pkg/pool-range/contracts/RangeMath.sol b/pkg/pool-range/contracts/RangeMath.sol new file mode 100644 index 000000000..483efadf5 --- /dev/null +++ b/pkg/pool-range/contracts/RangeMath.sol @@ -0,0 +1,83 @@ +// 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 . + +pragma solidity ^0.7.0; + +import "@balancer-labs/v2-solidity-utils/contracts/helpers/InputHelpers.sol"; +import "@balancer-labs/v2-solidity-utils/contracts/math/FixedPoint.sol"; +import "@balancer-labs/v2-solidity-utils/contracts/math/Math.sol"; +import "@balancer-labs/v2-pool-weighted/contracts/WeightedMath.sol"; + +// These functions start with an underscore, as if they were part of a contract and not a library. At some point this +// should be fixed. +// solhint-disable private-vars-leading-underscore + +library RangeMath { + using FixedPoint for uint256; + + // Computes how many tokens can be taken out of a pool if `amountIn` are sent, given the + // current balances and weights. + function _calcOutGivenIn( + uint256 balanceIn, + uint256 weightIn, + uint256 balanceOut, + uint256 weightOut, + uint256 amountIn, + uint256 factBalance + ) internal pure returns (uint256) { + /********************************************************************************************** + // outGivenIn // + // aO = _calcOutGivenIn(..) // + // if a0 exceeds factBalance, then a0 = factBalance // // + **********************************************************************************************/ + + return Math.min(factBalance, WeightedMath._calcOutGivenIn(balanceIn, weightIn, balanceOut, weightOut, amountIn)); + } + + function _calcBptOutGivenExactTokensIn( + uint256[] memory factBalances, + uint256[] memory amountsIn, + uint256 bptTotalSupply + ) internal pure returns (uint256) { + uint256 ratioMin = amountsIn[0].mulUp(FixedPoint.ONE).divDown(factBalances[0]); + uint256 i = 1; + while (i < factBalances.length && ratioMin > 0) { + ratioMin = Math.min(ratioMin, amountsIn[0].mulUp(FixedPoint.ONE).divDown(factBalances[0])); + } + + return bptTotalSupply.mulUp(ratioMin).divDown(FixedPoint.ONE); + } + + function _calcTokenInGivenExactBptOut( + uint256 factBalance, + uint256 bptAmountOut, + uint256 bptTotalSupply + ) internal pure returns (uint256) { + return bptTotalSupply.mulUp(bptAmountOut).divDown(factBalance); + } + + function _calcBptInGivenExactTokensOut( + uint256[] memory factBalances, + uint256[] memory amountsOut, + uint256 bptTotalSupply + ) internal pure returns (uint256) { + uint256 ratioMin = amountsOut[0].mulUp(FixedPoint.ONE).divDown(factBalances[0]); + uint256 i = 1; + while (i < factBalances.length && ratioMin > 0) { + ratioMin = Math.min(ratioMin, amountsOut[0].mulUp(FixedPoint.ONE).divDown(factBalances[0])); + } + + return bptTotalSupply.mulUp(ratioMin).divDown(FixedPoint.ONE); + } +} diff --git a/pkg/pool-range/contracts/RangePool.sol b/pkg/pool-range/contracts/RangePool.sol new file mode 100644 index 000000000..95a1050da --- /dev/null +++ b/pkg/pool-range/contracts/RangePool.sol @@ -0,0 +1,421 @@ +// 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 . + +pragma solidity ^0.7.0; +pragma experimental ABIEncoderV2; + +import "./BaseRangePool.sol"; +import "./RangePoolProtocolFees.sol"; + +/** + * @dev Range Pool with immutable weights. + */ +contract RangePool is BaseRangePool, RangePoolProtocolFees { + using FixedPoint for uint256; + + uint256 private constant _MAX_TOKENS = 10; + + uint256 private immutable _totalTokens; + + IERC20 internal immutable _token0; + IERC20 internal immutable _token1; + IERC20 internal immutable _token2; + IERC20 internal immutable _token3; + IERC20 internal immutable _token4; + IERC20 internal immutable _token5; + IERC20 internal immutable _token6; + IERC20 internal immutable _token7; + IERC20 internal immutable _token8; + IERC20 internal immutable _token9; + + // All token balances are normalized to behave as if the token had 18 decimals. We assume a token's decimals will + // not change throughout its lifetime, and store the corresponding scaling factor for each at construction time. + // These factors are always greater than or equal to one: tokens with more than 18 decimals are not supported. + + uint256 internal immutable _scalingFactor0; + uint256 internal immutable _scalingFactor1; + uint256 internal immutable _scalingFactor2; + uint256 internal immutable _scalingFactor3; + uint256 internal immutable _scalingFactor4; + uint256 internal immutable _scalingFactor5; + uint256 internal immutable _scalingFactor6; + uint256 internal immutable _scalingFactor7; + uint256 internal immutable _scalingFactor8; + uint256 internal immutable _scalingFactor9; + + uint256 internal immutable _normalizedWeight0; + uint256 internal immutable _normalizedWeight1; + uint256 internal immutable _normalizedWeight2; + uint256 internal immutable _normalizedWeight3; + uint256 internal immutable _normalizedWeight4; + uint256 internal immutable _normalizedWeight5; + uint256 internal immutable _normalizedWeight6; + uint256 internal immutable _normalizedWeight7; + uint256 internal immutable _normalizedWeight8; + uint256 internal immutable _normalizedWeight9; + + struct NewPoolParams { + string name; + string symbol; + IERC20[] tokens; + uint256[] normalizedWeights; + IRateProvider[] rateProviders; + address[] assetManagers; + uint256 swapFeePercentage; + } + + constructor( + NewPoolParams memory params, + IVault vault, + IProtocolFeePercentagesProvider protocolFeeProvider, + uint256 pauseWindowDuration, + uint256 bufferPeriodDuration, + address owner + ) + BaseRangePool( + vault, + params.name, + params.symbol, + params.tokens, + params.assetManagers, + params.swapFeePercentage, + pauseWindowDuration, + bufferPeriodDuration, + owner, + false + ) + ProtocolFeeCache( + protocolFeeProvider, + ProviderFeeIDs({ swap: ProtocolFeeType.SWAP, yield: ProtocolFeeType.YIELD, aum: ProtocolFeeType.AUM }) + ) + RangePoolProtocolFees(params.tokens.length, params.rateProviders) + { + uint256 numTokens = params.tokens.length; + InputHelpers.ensureInputLengthMatch(numTokens, params.normalizedWeights.length); + + _totalTokens = numTokens; + + // Ensure each normalized weight is above the minimum + uint256 normalizedSum = 0; + for (uint8 i = 0; i < numTokens; i++) { + uint256 normalizedWeight = params.normalizedWeights[i]; + + _require(normalizedWeight >= WeightedMath._MIN_WEIGHT, Errors.MIN_WEIGHT); + normalizedSum = normalizedSum.add(normalizedWeight); + } + // Ensure that the normalized weights sum to ONE + _require(normalizedSum == FixedPoint.ONE, Errors.NORMALIZED_WEIGHT_INVARIANT); + + // Immutable variables cannot be initialized inside an if statement, so we must do conditional assignments + _token0 = params.tokens[0]; + _token1 = params.tokens[1]; + _token2 = numTokens > 2 ? params.tokens[2] : IERC20(0); + _token3 = numTokens > 3 ? params.tokens[3] : IERC20(0); + _token4 = numTokens > 4 ? params.tokens[4] : IERC20(0); + _token5 = numTokens > 5 ? params.tokens[5] : IERC20(0); + _token6 = numTokens > 6 ? params.tokens[6] : IERC20(0); + _token7 = numTokens > 7 ? params.tokens[7] : IERC20(0); + _token8 = numTokens > 8 ? params.tokens[8] : IERC20(0); + _token9 = numTokens > 9 ? params.tokens[9] : IERC20(0); + + _scalingFactor0 = _computeScalingFactor(params.tokens[0]); + _scalingFactor1 = _computeScalingFactor(params.tokens[1]); + _scalingFactor2 = numTokens > 2 ? _computeScalingFactor(params.tokens[2]) : 0; + _scalingFactor3 = numTokens > 3 ? _computeScalingFactor(params.tokens[3]) : 0; + _scalingFactor4 = numTokens > 4 ? _computeScalingFactor(params.tokens[4]) : 0; + _scalingFactor5 = numTokens > 5 ? _computeScalingFactor(params.tokens[5]) : 0; + _scalingFactor6 = numTokens > 6 ? _computeScalingFactor(params.tokens[6]) : 0; + _scalingFactor7 = numTokens > 7 ? _computeScalingFactor(params.tokens[7]) : 0; + _scalingFactor8 = numTokens > 8 ? _computeScalingFactor(params.tokens[8]) : 0; + _scalingFactor9 = numTokens > 9 ? _computeScalingFactor(params.tokens[9]) : 0; + + _normalizedWeight0 = params.normalizedWeights[0]; + _normalizedWeight1 = params.normalizedWeights[1]; + _normalizedWeight2 = numTokens > 2 ? params.normalizedWeights[2] : 0; + _normalizedWeight3 = numTokens > 3 ? params.normalizedWeights[3] : 0; + _normalizedWeight4 = numTokens > 4 ? params.normalizedWeights[4] : 0; + _normalizedWeight5 = numTokens > 5 ? params.normalizedWeights[5] : 0; + _normalizedWeight6 = numTokens > 6 ? params.normalizedWeights[6] : 0; + _normalizedWeight7 = numTokens > 7 ? params.normalizedWeights[7] : 0; + _normalizedWeight8 = numTokens > 8 ? params.normalizedWeights[8] : 0; + _normalizedWeight9 = numTokens > 9 ? params.normalizedWeights[9] : 0; + } + + function _getTokenIndex(IERC20 token) internal view virtual override returns (uint256) { + // prettier-ignore + if (token == _token0) { return 0; } + else if (token == _token1) { return 1; } + else if (token == _token2) { return 2; } + else if (token == _token3) { return 3; } + else if (token == _token4) { return 4; } + else if (token == _token5) { return 5; } + else if (token == _token6) { return 6; } + else if (token == _token7) { return 7; } + else if (token == _token8) { return 8; } + else if (token == _token9) { return 9; } + else { + _revert(Errors.INVALID_TOKEN); + } + } + + function _getNormalizedWeight(IERC20 token) internal view virtual override returns (uint256) { + // prettier-ignore + if (token == _token0) { return _normalizedWeight0; } + else if (token == _token1) { return _normalizedWeight1; } + else if (token == _token2) { return _normalizedWeight2; } + else if (token == _token3) { return _normalizedWeight3; } + else if (token == _token4) { return _normalizedWeight4; } + else if (token == _token5) { return _normalizedWeight5; } + else if (token == _token6) { return _normalizedWeight6; } + else if (token == _token7) { return _normalizedWeight7; } + else if (token == _token8) { return _normalizedWeight8; } + else if (token == _token9) { return _normalizedWeight9; } + else { + _revert(Errors.INVALID_TOKEN); + } + } + + function _getNormalizedWeights() internal view virtual override returns (uint256[] memory) { + uint256 totalTokens = _getTotalTokens(); + uint256[] memory normalizedWeights = new uint256[](totalTokens); + + // prettier-ignore + { + normalizedWeights[0] = _normalizedWeight0; + normalizedWeights[1] = _normalizedWeight1; + if (totalTokens > 2) { normalizedWeights[2] = _normalizedWeight2; } else { return normalizedWeights; } + if (totalTokens > 3) { normalizedWeights[3] = _normalizedWeight3; } else { return normalizedWeights; } + if (totalTokens > 4) { normalizedWeights[4] = _normalizedWeight4; } else { return normalizedWeights; } + if (totalTokens > 5) { normalizedWeights[5] = _normalizedWeight5; } else { return normalizedWeights; } + if (totalTokens > 6) { normalizedWeights[6] = _normalizedWeight6; } else { return normalizedWeights; } + if (totalTokens > 7) { normalizedWeights[7] = _normalizedWeight7; } else { return normalizedWeights; } + if (totalTokens > 8) { normalizedWeights[8] = _normalizedWeight8; } else { return normalizedWeights; } + if (totalTokens > 9) { normalizedWeights[9] = _normalizedWeight9; } else { return normalizedWeights; } + } + + return normalizedWeights; + } + + function _getMaxTokens() internal pure virtual override returns (uint256) { + return _MAX_TOKENS; + } + + function _getTotalTokens() internal view virtual override returns (uint256) { + return _totalTokens; + } + + /** + * @dev Returns the scaling factor for one of the Pool's tokens. Reverts if `token` is not a token registered by the + * Pool. + */ + function _scalingFactor(IERC20 token) internal view virtual override returns (uint256) { + // prettier-ignore + if (token == _token0) { return _scalingFactor0; } + else if (token == _token1) { return _scalingFactor1; } + else if (token == _token2) { return _scalingFactor2; } + else if (token == _token3) { return _scalingFactor3; } + else if (token == _token4) { return _scalingFactor4; } + else if (token == _token5) { return _scalingFactor5; } + else if (token == _token6) { return _scalingFactor6; } + else if (token == _token7) { return _scalingFactor7; } + else if (token == _token8) { return _scalingFactor8; } + else if (token == _token9) { return _scalingFactor9; } + else { + _revert(Errors.INVALID_TOKEN); + } + } + + function _scalingFactors() internal view virtual override returns (uint256[] memory) { + uint256 totalTokens = _getTotalTokens(); + uint256[] memory scalingFactors = new uint256[](totalTokens); + + // prettier-ignore + { + scalingFactors[0] = _scalingFactor0; + scalingFactors[1] = _scalingFactor1; + if (totalTokens > 2) { scalingFactors[2] = _scalingFactor2; } else { return scalingFactors; } + if (totalTokens > 3) { scalingFactors[3] = _scalingFactor3; } else { return scalingFactors; } + if (totalTokens > 4) { scalingFactors[4] = _scalingFactor4; } else { return scalingFactors; } + if (totalTokens > 5) { scalingFactors[5] = _scalingFactor5; } else { return scalingFactors; } + if (totalTokens > 6) { scalingFactors[6] = _scalingFactor6; } else { return scalingFactors; } + if (totalTokens > 7) { scalingFactors[7] = _scalingFactor7; } else { return scalingFactors; } + if (totalTokens > 8) { scalingFactors[8] = _scalingFactor8; } else { return scalingFactors; } + if (totalTokens > 9) { scalingFactors[9] = _scalingFactor9; } else { return scalingFactors; } + } + + return scalingFactors; + } + + // Initialize + + function _onInitializePool( + bytes32 poolId, + address sender, + address recipient, + uint256[] memory scalingFactors, + bytes memory userData + ) internal virtual override returns (uint256, uint256[] memory) { + // Initialize `_athRateProduct` if the Pool will pay protocol fees on yield. + // Not initializing this here properly will cause all joins/exits to revert. + if (!_isExemptFromYieldProtocolFees()) _updateATHRateProduct(_getRateProduct(_getNormalizedWeights())); + + return super._onInitializePool(poolId, sender, recipient, scalingFactors, userData); + } + + // WeightedPoolProtocolFees functions + + function _beforeJoinExit(uint256[] memory preBalances, uint256[] memory normalizedWeights) + internal + virtual + override + returns (uint256, uint256) + { + uint256 supplyBeforeFeeCollection = totalSupply(); + uint256 invariant = WeightedMath._calculateInvariant(normalizedWeights, preBalances); + (uint256 protocolFeesToBeMinted, uint256 athRateProduct) = _getPreJoinExitProtocolFees( + invariant, + normalizedWeights, + supplyBeforeFeeCollection + ); + + // We then update the recorded value of `athRateProduct` to ensure we only collect fees on yield once. + // A zero value for `athRateProduct` represents that it is unchanged so we can skip updating it. + if (athRateProduct > 0) { + _updateATHRateProduct(athRateProduct); + } + + _payProtocolFees(protocolFeesToBeMinted); + + return (supplyBeforeFeeCollection.add(protocolFeesToBeMinted), invariant); + } + + function _afterJoinExit( + uint256 preJoinExitInvariant, + uint256[] memory preBalances, + uint256[] memory balanceDeltas, + uint256[] memory normalizedWeights, + uint256 preJoinExitSupply, + uint256 postJoinExitSupply + ) internal virtual override { + uint256 protocolFeesToBeMinted = _getPostJoinExitProtocolFees( + preJoinExitInvariant, + preBalances, + balanceDeltas, + normalizedWeights, + preJoinExitSupply, + postJoinExitSupply + ); + + _payProtocolFees(protocolFeesToBeMinted); + } + + function _updatePostJoinExit(uint256 postJoinExitInvariant) + internal + virtual + override(BaseRangePool, RangePoolProtocolFees) + { + RangePoolProtocolFees._updatePostJoinExit(postJoinExitInvariant); + } + + function _beforeProtocolFeeCacheUpdate() internal override { + // The `getRate()` function depends on the actual supply, which in turn depends on the cached protocol fee + // percentages. Changing these would therefore result in the rate changing, which is not acceptable as this is a + // sensitive value. + // Because of this, we pay any due protocol fees *before* updating the cache, making it so that the new + // percentages only affect future operation of the Pool, and not past fees. As a result, `getRate()` is + // unaffected by the cached protocol fee percentages changing. + + // Given that this operation is state-changing and relatively complex, we only allow it as long as the Pool is + // not paused. + _ensureNotPaused(); + + uint256 invariant = getInvariant(); + + (uint256 protocolFeesToBeMinted, uint256 athRateProduct) = _getPreJoinExitProtocolFees( + invariant, + _getNormalizedWeights(), + totalSupply() + ); + + _payProtocolFees(protocolFeesToBeMinted); + + // With the fees paid, we now store the current invariant and update the ATH rate product (if necessary), + // marking the Pool as free of protocol debt. + + _updatePostJoinExit(invariant); + if (athRateProduct > 0) { + _updateATHRateProduct(athRateProduct); + } + } + + /** + * @notice Returns the effective BPT supply. + * + * @dev This would be the same as `totalSupply` however the Pool owes debt to the Protocol in the form of unminted + * BPT, which will be minted immediately before the next join or exit. We need to take these into account since, + * even if they don't yet exist, they will effectively be included in any Pool operation that involves BPT. + * + * In the vast majority of cases, this function should be used instead of `totalSupply()`. + * + * **IMPORTANT NOTE**: calling this function within a Vault context (i.e. in the middle of a join or an exit) is + * potentially unsafe, since the returned value is manipulable. It is up to the caller to ensure safety. + * + * This is because this function calculates the invariant, which requires the state of the pool to be in sync + * with the state of the Vault. That condition may not be true in the middle of a join or an exit. + * + * To call this function safely, attempt to trigger the reentrancy guard in the Vault by calling a non-reentrant + * function before calling `getActualSupply`. That will make the transaction revert in an unsafe context. + * (See `VaultReentrancyLib.ensureNotInVaultContext` in pool-utils.) + * + * See https://forum.balancer.fi/t/reentrancy-vulnerability-scope-expanded/4345 for reference. + */ + function getActualSupply() external view returns (uint256) { + uint256 supply = totalSupply(); + + (uint256 protocolFeesToBeMinted, ) = _getPreJoinExitProtocolFees( + getInvariant(), + _getNormalizedWeights(), + supply + ); + + return supply.add(protocolFeesToBeMinted); + } + + function _onDisableRecoveryMode() internal override { + // Update the postJoinExitInvariant to the value of the currentInvariant, zeroing out any protocol swap fees. + _updatePostJoinExit(getInvariant()); + + // If the Pool has any protocol yield fees accrued then we update the athRateProduct to zero these out. + // If the current rate product is less than the athRateProduct then we do not perform this update. + // This prevents the Pool from paying protocol fees on the same yield twice if the rate product were to drop. + if (!_isExemptFromYieldProtocolFees()) { + uint256 athRateProduct = getATHRateProduct(); + uint256 rateProduct = _getRateProduct(_getNormalizedWeights()); + + if (rateProduct > athRateProduct) { + _updateATHRateProduct(rateProduct); + } + } + } + + function _isOwnerOnlyAction(bytes32 actionId) + internal + view + virtual + override(BasePool, RangePoolProtocolFees) + returns (bool) + { + return super._isOwnerOnlyAction(actionId); + } +} diff --git a/pkg/pool-range/contracts/RangePoolProtocolFees.sol b/pkg/pool-range/contracts/RangePoolProtocolFees.sol new file mode 100644 index 000000000..251ec6eba --- /dev/null +++ b/pkg/pool-range/contracts/RangePoolProtocolFees.sol @@ -0,0 +1,360 @@ +// 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 . + +pragma solidity ^0.7.0; +pragma experimental ABIEncoderV2; + +import "@balancer-labs/v2-interfaces/contracts/pool-utils/IRateProviderPool.sol"; +import "@balancer-labs/v2-pool-utils/contracts/external-fees/ProtocolFeeCache.sol"; +import "@balancer-labs/v2-pool-utils/contracts/external-fees/InvariantGrowthProtocolSwapFees.sol"; + +import "./BaseRangePool.sol"; + +abstract contract RangePoolProtocolFees is BaseRangePool, ProtocolFeeCache, IRateProviderPool { + using FixedPoint for uint256; + using WordCodec for bytes32; + + // Rate providers are used only for computing yield fees; they do not inform swap/join/exit. + IRateProvider internal immutable _rateProvider0; + IRateProvider internal immutable _rateProvider1; + IRateProvider internal immutable _rateProvider2; + IRateProvider internal immutable _rateProvider3; + IRateProvider internal immutable _rateProvider4; + IRateProvider internal immutable _rateProvider5; + IRateProvider internal immutable _rateProvider6; + IRateProvider internal immutable _rateProvider7; + + bool internal immutable _exemptFromYieldFees; + + // All-time high value of the weighted product of the pool's token rates. Comparing such weighted products across + // time provides a measure of the pool's growth resulting from rate changes. The pool also grows due to swap fees, + // but that growth is captured in the invariant; rate growth is not. + uint256 private _athRateProduct; + + // This Pool pays protocol fees by measuring the growth of the invariant between joins and exits. Since weights are + // immutable, the invariant only changes due to accumulated swap fees, which saves gas by freeing the Pool + // from performing any computation or accounting associated with protocol fees during swaps. + // This mechanism requires keeping track of the invariant after the last join or exit. + // + // The maximum value of the invariant is the maximum allowable balance in the Vault (2**112) multiplied by the + // largest possible scaling factor (10**18 for a zero decimals token). The largest invariant is then + // 2**112 * 10**18 ~= 2**172, which means that to save gas we can place this in BasePool's `_miscData`. + uint256 private constant _LAST_POST_JOINEXIT_INVARIANT_OFFSET = 0; + uint256 private constant _LAST_POST_JOINEXIT_INVARIANT_BIT_LENGTH = 192; + + event ATHRateProductUpdated(uint256 oldATHRateProduct, uint256 newATHRateProduct); + + constructor(uint256 numTokens, IRateProvider[] memory rateProviders) { + _require(numTokens <= 8, Errors.MAX_TOKENS); + InputHelpers.ensureInputLengthMatch(numTokens, rateProviders.length); + + _exemptFromYieldFees = _getYieldFeeExemption(rateProviders); + + _rateProvider0 = rateProviders[0]; + _rateProvider1 = rateProviders[1]; + _rateProvider2 = numTokens > 2 ? rateProviders[2] : IRateProvider(0); + _rateProvider3 = numTokens > 3 ? rateProviders[3] : IRateProvider(0); + _rateProvider4 = numTokens > 4 ? rateProviders[4] : IRateProvider(0); + _rateProvider5 = numTokens > 5 ? rateProviders[5] : IRateProvider(0); + _rateProvider6 = numTokens > 6 ? rateProviders[6] : IRateProvider(0); + _rateProvider7 = numTokens > 7 ? rateProviders[7] : IRateProvider(0); + } + + function _getYieldFeeExemption(IRateProvider[] memory rateProviders) internal pure returns (bool) { + // If we know that no rate providers are set then we can skip yield fees logic. + // If any tokens have rate providers, then set `_exemptFromYieldFees` to false, otherwise leave it true. + for (uint256 i = 0; i < rateProviders.length; i++) { + if (rateProviders[i] != IRateProvider(0)) { + return false; + } + } + return true; + } + + /** + * @dev Returns whether the pool is exempt from protocol fees on yield. + */ + function _isExemptFromYieldProtocolFees() internal view returns (bool) { + return _exemptFromYieldFees; + } + + /** + * @notice Returns the value of the invariant after the last join or exit operation. + */ + function getLastPostJoinExitInvariant() public view returns (uint256) { + return + _getMiscData().decodeUint(_LAST_POST_JOINEXIT_INVARIANT_OFFSET, _LAST_POST_JOINEXIT_INVARIANT_BIT_LENGTH); + } + + /** + * @notice Returns the all time high value for the weighted product of the Pool's tokens' rates. + * @dev Yield protocol fees are only charged when this value is exceeded. + */ + function getATHRateProduct() public view returns (uint256) { + return _athRateProduct; + } + + function getRateProviders() external view override returns (IRateProvider[] memory) { + uint256 totalTokens = _getTotalTokens(); + IRateProvider[] memory providers = new IRateProvider[](totalTokens); + + // prettier-ignore + { + providers[0] = _rateProvider0; + providers[1] = _rateProvider1; + if (totalTokens > 2) { providers[2] = _rateProvider2; } else { return providers; } + if (totalTokens > 3) { providers[3] = _rateProvider3; } else { return providers; } + if (totalTokens > 4) { providers[4] = _rateProvider4; } else { return providers; } + if (totalTokens > 5) { providers[5] = _rateProvider5; } else { return providers; } + if (totalTokens > 6) { providers[6] = _rateProvider6; } else { return providers; } + if (totalTokens > 7) { providers[7] = _rateProvider7; } else { return providers; } + } + + return providers; + } + + // Protocol Fees + + /** + * @dev Returns the percentage of the Pool's supply which corresponds to protocol fees on swaps accrued by the Pool. + * @param preJoinExitInvariant - The Pool's invariant prior to the join/exit *before* minting protocol fees. + * @param protocolSwapFeePercentage - The percentage of swap fees which are paid to the protocol. + * @return swapProtocolFeesPercentage - The percentage of the Pool which corresponds to protocol fees on swaps. + */ + function _getSwapProtocolFeesPoolPercentage(uint256 preJoinExitInvariant, uint256 protocolSwapFeePercentage) + internal + view + returns (uint256) + { + // Before joins and exits, we measure the growth of the invariant compared to the invariant after the last join + // or exit, which will have been caused by swap fees, and use it to mint BPT as protocol fees. This dilutes all + // LPs, which means that new LPs will join the pool debt-free, and exiting LPs will pay any amounts due + // before leaving. + + return + InvariantGrowthProtocolSwapFees.getProtocolOwnershipPercentage( + preJoinExitInvariant.divDown(getLastPostJoinExitInvariant()), + FixedPoint.ONE, // Supply has not changed so supplyGrowthRatio = 1 + protocolSwapFeePercentage + ); + } + + /** + * @dev Returns the percentage of the Pool's supply which corresponds to protocol fees on yield accrued by the Pool. + * @param normalizedWeights - The Pool's normalized token weights. + * @return yieldProtocolFeesPercentage - The percentage of the Pool which corresponds to protocol fees on yield. + * @return athRateProduct - The new all-time-high rate product if it has increased, otherwise zero. + */ + function _getYieldProtocolFeesPoolPercentage(uint256[] memory normalizedWeights) + internal + view + returns (uint256, uint256) + { + if (_isExemptFromYieldProtocolFees()) return (0, 0); + + // Yield manifests in the Pool by individual tokens becoming more valuable, we convert this into comparable + // units by applying a rate to get the equivalent balance of non-yield-bearing tokens + // + // non-yield-bearing balance = rate * yield-bearing balance + // x'i = ri * xi + // + // To measure the amount of fees to pay due to yield, we take advantage of the fact that scaling the + // Pool's balances results in a scaling factor being applied to the original invariant. + // + // I(r1 * x1, r2 * x2) = (r1 * x1)^w1 * (r2 * x2)^w2 + // = (r1)^w1 * (r2)^w2 * (x1)^w1 * (x2)^w2 + // = I(r1, r2) * I(x1, x2) + // + // We then only need to measure the growth of this scaling factor to measure how the value of the BPT token + // increases due to yield; we can ignore the invariant calculated from the Pool's balances as these cancel. + // We then have the result: + // + // invariantGrowthRatio = I(r1_new, r2_new) / I(r1_old, r2_old) = rateProduct / athRateProduct + + uint256 athRateProduct = _athRateProduct; + uint256 rateProduct = _getRateProduct(normalizedWeights); + + // Only charge yield fees if we've exceeded the all time high of Pool value generated through yield. + // i.e. if the Pool makes a loss through the yield strategies then it shouldn't charge fees until it's + // been recovered. + if (rateProduct <= athRateProduct) return (0, 0); + + return ( + InvariantGrowthProtocolSwapFees.getProtocolOwnershipPercentage( + rateProduct.divDown(athRateProduct), + FixedPoint.ONE, // Supply has not changed so supplyGrowthRatio = 1 + getProtocolFeePercentageCache(ProtocolFeeType.YIELD) + ), + rateProduct + ); + } + + function _updateATHRateProduct(uint256 rateProduct) internal { + emit ATHRateProductUpdated(_athRateProduct, rateProduct); + + _athRateProduct = rateProduct; + } + + /** + * @dev Returns the amount of BPT to be minted as protocol fees prior to processing a join/exit. + * Note that this isn't a view function. This function automatically updates `_athRateProduct` to ensure that + * proper accounting is performed to prevent charging duplicate protocol fees. + * @param preJoinExitInvariant - The Pool's invariant prior to the join/exit. + * @param normalizedWeights - The Pool's normalized token weights. + * @param preJoinExitSupply - The Pool's total supply prior to the join/exit *before* minting protocol fees. + * @return protocolFeesToBeMinted - The amount of BPT to be minted as protocol fees. + * @return athRateProduct - The new all-time-high rate product if it has increased, otherwise zero. + */ + function _getPreJoinExitProtocolFees( + uint256 preJoinExitInvariant, + uint256[] memory normalizedWeights, + uint256 preJoinExitSupply + ) internal view returns (uint256, uint256) { + uint256 protocolSwapFeesPoolPercentage = _getSwapProtocolFeesPoolPercentage( + preJoinExitInvariant, + getProtocolFeePercentageCache(ProtocolFeeType.SWAP) + ); + (uint256 protocolYieldFeesPoolPercentage, uint256 athRateProduct) = _getYieldProtocolFeesPoolPercentage( + normalizedWeights + ); + + return ( + ExternalFees.bptForPoolOwnershipPercentage( + preJoinExitSupply, + protocolSwapFeesPoolPercentage + protocolYieldFeesPoolPercentage + ), + athRateProduct + ); + } + + /** + * @dev Returns the amount of BPT to be minted to pay protocol fees on swap fees accrued during a join/exit. + * Note that this isn't a view function. This function automatically updates `_lastPostJoinExitInvariant` to + * ensure that proper accounting is performed to prevent charging duplicate protocol fees. + * @param preJoinExitInvariant - The Pool's invariant prior to the join/exit. + * @param preBalances - The Pool's balances prior to the join/exit. + * @param balanceDeltas - The changes to the Pool's balances due to the join/exit. + * @param normalizedWeights - The Pool's normalized token weights. + * @param preJoinExitSupply - The Pool's total supply prior to the join/exit *after* minting protocol fees. + * @param postJoinExitSupply - The Pool's total supply after the join/exit. + */ + function _getPostJoinExitProtocolFees( + uint256 preJoinExitInvariant, + uint256[] memory preBalances, + uint256[] memory balanceDeltas, + uint256[] memory normalizedWeights, + uint256 preJoinExitSupply, + uint256 postJoinExitSupply + ) internal returns (uint256) { + bool isJoin = postJoinExitSupply >= preJoinExitSupply; + + // Compute the post balances by adding or removing the deltas. + for (uint256 i = 0; i < preBalances.length; ++i) { + preBalances[i] = isJoin + ? SafeMath.add(preBalances[i], balanceDeltas[i]) + : SafeMath.sub(preBalances[i], balanceDeltas[i]); + } + + // preBalances have now been mutated to reflect the postJoinExit balances. + uint256 postJoinExitInvariant = WeightedMath._calculateInvariant(normalizedWeights, preBalances); + uint256 protocolSwapFeePercentage = getProtocolFeePercentageCache(ProtocolFeeType.SWAP); + + _updatePostJoinExit(postJoinExitInvariant); + // We return immediately if the fee percentage is zero to avoid unnecessary computation. + if (protocolSwapFeePercentage == 0) return 0; + + uint256 protocolFeeAmount = InvariantGrowthProtocolSwapFees.calcDueProtocolFees( + postJoinExitInvariant.divDown(preJoinExitInvariant), + preJoinExitSupply, + postJoinExitSupply, + protocolSwapFeePercentage + ); + + return protocolFeeAmount; + } + + function _updatePostJoinExit(uint256 postJoinExitInvariant) internal virtual override { + // After all joins and exits we store the post join/exit invariant in order to compute growth due to swap fees + // in the next one. + _setMiscData( + _getMiscData().insertUint( + postJoinExitInvariant, + _LAST_POST_JOINEXIT_INVARIANT_OFFSET, + _LAST_POST_JOINEXIT_INVARIANT_BIT_LENGTH + ) + ); + } + + // Helper functions + + /** + * @notice Returns the contribution to the total rate product from a token with the given weight and rate provider. + */ + function _getRateFactor(uint256 normalizedWeight, IRateProvider provider) internal view returns (uint256) { + return provider == IRateProvider(0) ? FixedPoint.ONE : provider.getRate().powDown(normalizedWeight); + } + + /** + * @dev Returns the weighted product of all the token rates. + */ + function _getRateProduct(uint256[] memory normalizedWeights) internal view returns (uint256) { + uint256 totalTokens = normalizedWeights.length; + + uint256 rateProduct = FixedPoint.mulDown( + _getRateFactor(normalizedWeights[0], _rateProvider0), + _getRateFactor(normalizedWeights[1], _rateProvider1) + ); + + if (totalTokens > 2) { + rateProduct = rateProduct.mulDown(_getRateFactor(normalizedWeights[2], _rateProvider2)); + } else { + return rateProduct; + } + if (totalTokens > 3) { + rateProduct = rateProduct.mulDown(_getRateFactor(normalizedWeights[3], _rateProvider3)); + } else { + return rateProduct; + } + if (totalTokens > 4) { + rateProduct = rateProduct.mulDown(_getRateFactor(normalizedWeights[4], _rateProvider4)); + } else { + return rateProduct; + } + if (totalTokens > 5) { + rateProduct = rateProduct.mulDown(_getRateFactor(normalizedWeights[5], _rateProvider5)); + } else { + return rateProduct; + } + if (totalTokens > 6) { + rateProduct = rateProduct.mulDown(_getRateFactor(normalizedWeights[6], _rateProvider6)); + } else { + return rateProduct; + } + if (totalTokens > 7) { + rateProduct = rateProduct.mulDown(_getRateFactor(normalizedWeights[7], _rateProvider7)); + } + + return rateProduct; + } + + function _isOwnerOnlyAction(bytes32 actionId) + internal + view + virtual + override(BasePool, BasePoolAuthorization) + returns (bool) + { + return super._isOwnerOnlyAction(actionId); + } +} diff --git a/pkg/pool-range/foundry.toml b/pkg/pool-range/foundry.toml new file mode 120000 index 000000000..2d554be76 --- /dev/null +++ b/pkg/pool-range/foundry.toml @@ -0,0 +1 @@ +../../foundry.toml \ No newline at end of file diff --git a/pkg/pool-range/hardhat.config.ts b/pkg/pool-range/hardhat.config.ts new file mode 100644 index 000000000..dff1620d7 --- /dev/null +++ b/pkg/pool-range/hardhat.config.ts @@ -0,0 +1,45 @@ +import '@nomiclabs/hardhat-ethers'; +import '@nomiclabs/hardhat-waffle'; +import "hardhat-contract-sizer"; +import 'hardhat-ignore-warnings'; + +import { hardhatBaseConfig } from '@balancer-labs/v2-common'; +import { name } from './package.json'; + +import { task } from 'hardhat/config'; +import { TASK_COMPILE } from 'hardhat/builtin-tasks/task-names'; +import overrideQueryFunctions from '@balancer-labs/v2-helpers/plugins/overrideQueryFunctions'; + +task(TASK_COMPILE).setAction(overrideQueryFunctions); + +export default { + networks: { + hardhat: { + allowUnlimitedContractSize: true, + }, + }, + solidity: { + compilers: [ + { + version: "0.7.3", + settings: { + optimizer: { + enabled: true, + runs: 9999, + }, + }, + }, + ], + overrides: { ...hardhatBaseConfig.overrides(name) }, + }, + warnings: hardhatBaseConfig.warnings, + contractSizer: { + alphaSort: true, + disambiguatePaths: false, + runOnCompile: true, + strict: true, + }, + paths: { + sources: "./contracts", + }, +}; diff --git a/pkg/pool-range/package.json b/pkg/pool-range/package.json new file mode 100644 index 000000000..3b4efc23d --- /dev/null +++ b/pkg/pool-range/package.json @@ -0,0 +1,72 @@ +{ + "name": "@balancer-labs/v2-pool-range", + "version": "2.0.1", + "description": "Balancer V2 Range Pools", + "license": "GPL-3.0-only", + "homepage": "https://github.com/balancer-labs/balancer-v2-monorepo/tree/master/pkg/pool-weighted#readme", + "repository": { + "type": "git", + "url": "https://github.com/balancer-labs/balancer-v2-monorepo.git", + "directory": "pkg/pool-range" + }, + "bugs": { + "url": "https://github.com/balancer-labs/balancer-v2-monorepo/issues" + }, + "files": [ + "contracts/**/*", + "!contracts/test/*" + ], + "scripts": { + "build": "yarn compile", + "compile": "hardhat compile && rm -rf artifacts/build-info", + "compile:watch": "nodemon --ext sol --exec yarn compile", + "lint": "yarn lint:solidity && yarn lint:typescript", + "lint:solidity": "solhint 'contracts/**/*.sol'", + "lint:typescript": "NODE_NO_WARNINGS=1 eslint . --ext .ts --ignore-path ../../.eslintignore --max-warnings 0", + "test": "yarn compile && mocha --extension ts --require hardhat/register --require @balancer-labs/v2-common/setupTests --recursive", + "test:fast": "yarn compile && mocha --extension ts --require hardhat/register --require @balancer-labs/v2-common/setupTests --recursive --parallel --exit", + "test:watch": "nodemon --ext js,ts --watch test --watch lib --exec 'clear && yarn test --no-compile'", + "test-fuzz": "forge test" + }, + "devDependencies": { + "@balancer-labs/balancer-js": "workspace:*", + "@balancer-labs/solidity-toolbox": "workspace:*", + "@balancer-labs/v2-common": "workspace:*", + "@balancer-labs/v2-helpers": "workspace:*", + "@balancer-labs/v2-interfaces": "workspace:*", + "@balancer-labs/v2-pool-utils": "workspace:*", + "@balancer-labs/v2-pool-weighted": "workspace:*", + "@balancer-labs/v2-solidity-utils": "workspace:*", + "@nomiclabs/hardhat-ethers": "^2.2.1", + "@nomiclabs/hardhat-waffle": "^2.0.3", + "@types/chai": "^4.3.3", + "@types/lodash": "^4.14.186", + "@types/mocha": "^10.0.0", + "@types/node": "^14.14.31", + "@typescript-eslint/eslint-plugin": "^5.41.0", + "@typescript-eslint/parser": "^5.41.0", + "chai": "^4.3.6", + "decimal.js": "^10.4.2", + "eslint": "^8.26.0", + "eslint-plugin-mocha-no-only": "^1.1.1", + "eslint-plugin-prettier": "^4.2.1", + "ethereum-waffle": "^3.4.4", + "ethers": "^5.7.2", + "hardhat": "^2.12.5", + "hardhat-contract-sizer": "^2.10.0", + "hardhat-ignore-warnings": "^0.2.4", + "lodash.frompairs": "^4.0.1", + "lodash.pick": "^4.4.0", + "lodash.range": "^3.2.0", + "lodash.times": "^4.3.2", + "lodash.zip": "^4.2.0", + "mocha": "^10.1.0", + "nodemon": "^2.0.20", + "prettier": "^2.7.1", + "prettier-plugin-solidity": "v1.0.0-alpha.59", + "solhint": "^3.2.0", + "solhint-plugin-prettier": "^0.0.4", + "ts-node": "^10.9.1", + "typescript": "^4.0.2" + } +} diff --git a/pkg/pool-range/tsconfig.json b/pkg/pool-range/tsconfig.json new file mode 100644 index 000000000..b4e69ae1f --- /dev/null +++ b/pkg/pool-range/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + } +} diff --git a/yarn.lock b/yarn.lock index fbc92feee..31746c1cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -302,6 +302,52 @@ __metadata: languageName: unknown linkType: soft +"@balancer-labs/v2-pool-range@workspace:pkg/pool-range": + version: 0.0.0-use.local + resolution: "@balancer-labs/v2-pool-range@workspace:pkg/pool-range" + dependencies: + "@balancer-labs/balancer-js": "workspace:*" + "@balancer-labs/solidity-toolbox": "workspace:*" + "@balancer-labs/v2-common": "workspace:*" + "@balancer-labs/v2-helpers": "workspace:*" + "@balancer-labs/v2-interfaces": "workspace:*" + "@balancer-labs/v2-pool-utils": "workspace:*" + "@balancer-labs/v2-pool-weighted": "workspace:*" + "@balancer-labs/v2-solidity-utils": "workspace:*" + "@nomiclabs/hardhat-ethers": "npm:^2.2.1" + "@nomiclabs/hardhat-waffle": "npm:^2.0.3" + "@types/chai": "npm:^4.3.3" + "@types/lodash": "npm:^4.14.186" + "@types/mocha": "npm:^10.0.0" + "@types/node": "npm:^14.14.31" + "@typescript-eslint/eslint-plugin": "npm:^5.41.0" + "@typescript-eslint/parser": "npm:^5.41.0" + chai: "npm:^4.3.6" + decimal.js: "npm:^10.4.2" + eslint: "npm:^8.26.0" + eslint-plugin-mocha-no-only: "npm:^1.1.1" + eslint-plugin-prettier: "npm:^4.2.1" + ethereum-waffle: "npm:^3.4.4" + ethers: "npm:^5.7.2" + hardhat: "npm:^2.12.5" + hardhat-contract-sizer: "npm:^2.10.0" + hardhat-ignore-warnings: "npm:^0.2.4" + lodash.frompairs: "npm:^4.0.1" + lodash.pick: "npm:^4.4.0" + lodash.range: "npm:^3.2.0" + lodash.times: "npm:^4.3.2" + lodash.zip: "npm:^4.2.0" + mocha: "npm:^10.1.0" + nodemon: "npm:^2.0.20" + prettier: "npm:^2.7.1" + prettier-plugin-solidity: "npm:v1.0.0-alpha.59" + solhint: "npm:^3.2.0" + solhint-plugin-prettier: "npm:^0.0.4" + ts-node: "npm:^10.9.1" + typescript: "npm:^4.0.2" + languageName: unknown + linkType: soft + "@balancer-labs/v2-pool-stable@workspace:*, @balancer-labs/v2-pool-stable@workspace:pkg/pool-stable": version: 0.0.0-use.local resolution: "@balancer-labs/v2-pool-stable@workspace:pkg/pool-stable" @@ -601,6 +647,13 @@ __metadata: languageName: node linkType: hard +"@colors/colors@npm:1.5.0": + version: 1.5.0 + resolution: "@colors/colors@npm:1.5.0" + checksum: 5e08870799494f68e5b3b79e9a337bbf5fd7e634904fbbe642769921bf158fe458c41c888f88edf051b78c5325e3339970f00b24e31421c3480bb58f02687218 + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -4153,6 +4206,19 @@ __metadata: languageName: node linkType: hard +"cli-table3@npm:^0.6.0": + version: 0.6.5 + resolution: "cli-table3@npm:0.6.5" + dependencies: + "@colors/colors": "npm:1.5.0" + string-width: "npm:^4.2.0" + dependenciesMeta: + "@colors/colors": + optional: true + checksum: 7004ba985cd532797da5cd286670a717ecc88ce6b6e3f7a3f5ad23cfbc42305e8b0a6f3e91ae5aa520cc762c0f5f1bac2912c900c431ae33e801a8ed8aa68ca0 + languageName: node + linkType: hard + "cliui@npm:^3.2.0": version: 3.2.0 resolution: "cliui@npm:3.2.0" @@ -6779,6 +6845,19 @@ __metadata: languageName: node linkType: hard +"hardhat-contract-sizer@npm:^2.10.0": + version: 2.10.1 + resolution: "hardhat-contract-sizer@npm:2.10.1" + dependencies: + chalk: "npm:^4.0.0" + cli-table3: "npm:^0.6.0" + strip-ansi: "npm:^6.0.0" + peerDependencies: + hardhat: ^2.0.0 + checksum: 2dc683d81aa2292fec137c31c679a9d6ead7e881194fab41de2e0715e40618964917ef0d67e5ed9d881e5ee64d88d9f4cd4a52dc6416412462ded961c492b6b4 + languageName: node + linkType: hard + "hardhat-ignore-warnings@npm:^0.2.4": version: 0.2.8 resolution: "hardhat-ignore-warnings@npm:0.2.8" From abce419ffddf5b7ab623bb93fb27bf2cdfa1fc4d Mon Sep 17 00:00:00 2001 From: dcpp Date: Thu, 28 Aug 2025 17:47:46 +0300 Subject: [PATCH 02/13] pool-range: add virtual balances to storage --- pkg/pool-range/contracts/BaseRangePool.sol | 130 ++++++++---------- .../contracts/ExternalRangeMath.sol | 60 ++++++++ pkg/pool-range/contracts/RangeMath.sol | 8 -- pkg/pool-range/contracts/RangePool.sol | 32 +++++ pkg/pool-range/contracts/RangePoolFactory.sol | 80 +++++++++++ 5 files changed, 227 insertions(+), 83 deletions(-) create mode 100644 pkg/pool-range/contracts/ExternalRangeMath.sol create mode 100644 pkg/pool-range/contracts/RangePoolFactory.sol diff --git a/pkg/pool-range/contracts/BaseRangePool.sol b/pkg/pool-range/contracts/BaseRangePool.sol index e55e59247..1952bdb9d 100644 --- a/pkg/pool-range/contracts/BaseRangePool.sol +++ b/pkg/pool-range/contracts/BaseRangePool.sol @@ -87,6 +87,31 @@ abstract contract BaseRangePool is BaseGeneralPool { */ function _getNormalizedWeights() internal view virtual returns (uint256[] memory); + /** + * @dev Returns the virtual balance of `token`. + */ + function _getVirtualBalance(IERC20 token) internal view virtual returns (uint256); + + /** + * @dev Increases the virtual balance of `token`. + */ + function _increaseVirtualBalance(IERC20 token, uint256 delta) internal virtual; + + /** + * @dev Decreases the virtual balance of `token`. + */ + function _decreaseVirtualBalance(IERC20 token, uint256 delta) internal virtual; + + /** + * @dev Increases the virtual balances of `join`. + */ + function _increaseVirtualBalances(uint256[] memory deltas) internal virtual; + + /** + * @dev Decreases the virtual balances of `exit`. + */ + function _decreaseVirtualBalances(uint256[] memory deltas) internal virtual; + /** * @dev Returns the current value of the invariant. * **IMPORTANT NOTE**: calling this function within a Vault context (i.e. in the middle of a join or an exit) is @@ -122,40 +147,47 @@ abstract contract BaseRangePool is BaseGeneralPool { function _onSwapGivenIn( SwapRequest memory swapRequest, - uint256[] memory factBalances, - uint256 virtualBalanceIn, - uint256 virtualBalanceOut + uint256[] memory balances, + uint256 /*indexIn*/, + uint256 /*indexOut*/ ) internal virtual override returns (uint256) { uint256 tokenOutIdx = _getTokenIndex(swapRequest.tokenOut); - _require(tokenOutIdx < factBalances.length, Errors.OUT_OF_BOUNDS); - return - RangeMath._calcOutGivenIn( - virtualBalanceIn, + _require(tokenOutIdx < balances.length, Errors.OUT_OF_BOUNDS); + uint256 amountOut = RangeMath._calcOutGivenIn( + _getVirtualBalance(swapRequest.tokenIn), _getNormalizedWeight(swapRequest.tokenIn), - virtualBalanceOut, + _getVirtualBalance(swapRequest.tokenOut), _getNormalizedWeight(swapRequest.tokenOut), swapRequest.amount, - factBalances[tokenOutIdx] + balances[tokenOutIdx] ); + + _increaseVirtualBalance(swapRequest.tokenIn, swapRequest.amount); + _decreaseVirtualBalance(swapRequest.tokenOut, amountOut); + return amountOut; } function _onSwapGivenOut( SwapRequest memory swapRequest, - uint256[] memory factBalances, - uint256 virtualBalanceIn, - uint256 virtualBalanceOut + uint256[] memory balances, + uint256 /*indexIn*/, + uint256 /*indexOut*/ ) internal virtual override returns (uint256) { uint256 tokenOutIdx = _getTokenIndex(swapRequest.tokenOut); - _require(tokenOutIdx < factBalances.length, Errors.OUT_OF_BOUNDS); - _require(factBalances[tokenOutIdx] >= swapRequest.amount, Errors.INSUFFICIENT_BALANCE); - return + _require(tokenOutIdx < balances.length, Errors.OUT_OF_BOUNDS); + _require(balances[tokenOutIdx] >= swapRequest.amount, Errors.INSUFFICIENT_BALANCE); + uint256 amountIn = WeightedMath._calcInGivenOut( - virtualBalanceIn, + _getVirtualBalance(swapRequest.tokenIn), _getNormalizedWeight(swapRequest.tokenIn), - virtualBalanceOut, + _getVirtualBalance(swapRequest.tokenOut), _getNormalizedWeight(swapRequest.tokenOut), swapRequest.amount ); + + _increaseVirtualBalance(swapRequest.tokenIn, amountIn); + _decreaseVirtualBalance(swapRequest.tokenOut, swapRequest.amount); + return amountIn; } /** @@ -249,6 +281,8 @@ abstract contract BaseRangePool is BaseGeneralPool { userData ); + _increaseVirtualBalances(amountsIn); + _afterJoinExit( preJoinExitInvariant, balances, @@ -278,8 +312,6 @@ abstract contract BaseRangePool is BaseGeneralPool { if (kind == WeightedPoolUserData.JoinKind.EXACT_TOKENS_IN_FOR_BPT_OUT) { return _joinExactTokensInForBPTOut(balances, scalingFactors, totalSupply, userData); - } else if (kind == WeightedPoolUserData.JoinKind.TOKEN_IN_FOR_EXACT_BPT_OUT) { - return _joinTokenInForExactBPTOut(balances, totalSupply, userData); } else if (kind == WeightedPoolUserData.JoinKind.ALL_TOKENS_IN_FOR_EXACT_BPT_OUT) { return _joinAllTokensInForExactBPTOut(balances, totalSupply, userData); } else { @@ -309,30 +341,6 @@ abstract contract BaseRangePool is BaseGeneralPool { return (bptAmountOut, amountsIn); } - function _joinTokenInForExactBPTOut( - uint256[] memory balances, - uint256 totalSupply, - bytes memory userData - ) private pure returns (uint256, uint256[] memory) { - (uint256 bptAmountOut, uint256 tokenIndex) = userData.tokenInForExactBptOut(); - // Note that there is no maximum amountIn parameter: this is handled by `IVault.joinPool`. - - _require(tokenIndex < balances.length, Errors.OUT_OF_BOUNDS); - - uint256 amountIn = RangeMath._calcTokenInGivenExactBptOut( - balances[tokenIndex], - bptAmountOut, - totalSupply - ); - - // We join in a single token, so we initialize amountsIn with zeros - uint256[] memory amountsIn = new uint256[](balances.length); - // And then assign the result to the selected token - amountsIn[tokenIndex] = amountIn; - - return (bptAmountOut, amountsIn); - } - function _joinAllTokensInForExactBPTOut( uint256[] memory balances, uint256 totalSupply, @@ -371,6 +379,8 @@ abstract contract BaseRangePool is BaseGeneralPool { userData ); + _decreaseVirtualBalances(amountsOut); + _afterJoinExit( preJoinExitInvariant, balances, @@ -391,16 +401,14 @@ abstract contract BaseRangePool is BaseGeneralPool { function _doExit( address, uint256[] memory balances, - uint256[] memory normalizedWeights, + uint256[] memory /*normalizedWeights*/, uint256[] memory scalingFactors, uint256 totalSupply, bytes memory userData ) internal view virtual returns (uint256, uint256[] memory) { WeightedPoolUserData.ExitKind kind = userData.exitKind(); - if (kind == WeightedPoolUserData.ExitKind.EXACT_BPT_IN_FOR_ONE_TOKEN_OUT) { - return _exitExactBPTInForTokenOut(balances, normalizedWeights, totalSupply, userData); - } else if (kind == WeightedPoolUserData.ExitKind.EXACT_BPT_IN_FOR_TOKENS_OUT) { + if (kind == WeightedPoolUserData.ExitKind.EXACT_BPT_IN_FOR_TOKENS_OUT) { return _exitExactBPTInForTokensOut(balances, totalSupply, userData); } else if (kind == WeightedPoolUserData.ExitKind.BPT_IN_FOR_EXACT_TOKENS_OUT) { return _exitBPTInForExactTokensOut(balances, scalingFactors, totalSupply, userData); @@ -409,34 +417,6 @@ abstract contract BaseRangePool is BaseGeneralPool { } } - function _exitExactBPTInForTokenOut( - uint256[] memory balances, - uint256[] memory normalizedWeights, - uint256 totalSupply, - bytes memory userData - ) private view returns (uint256, uint256[] memory) { - (uint256 bptAmountIn, uint256 tokenIndex) = userData.exactBptInForTokenOut(); - // Note that there is no minimum amountOut parameter: this is handled by `IVault.exitPool`. - - _require(tokenIndex < balances.length, Errors.OUT_OF_BOUNDS); - - uint256 amountOut = WeightedMath._calcTokenOutGivenExactBptIn( - balances[tokenIndex], - normalizedWeights[tokenIndex], - bptAmountIn, - totalSupply, - getSwapFeePercentage() - ); - - // This is an exceptional situation in which the fee is charged on a token out instead of a token in. - // We exit in a single token, so we initialize amountsOut with zeros - uint256[] memory amountsOut = new uint256[](balances.length); - // And then assign the result to the selected token - amountsOut[tokenIndex] = amountOut; - - return (bptAmountIn, amountsOut); - } - function _exitExactBPTInForTokensOut( uint256[] memory balances, uint256 totalSupply, diff --git a/pkg/pool-range/contracts/ExternalRangeMath.sol b/pkg/pool-range/contracts/ExternalRangeMath.sol new file mode 100644 index 000000000..9e4a85cb7 --- /dev/null +++ b/pkg/pool-range/contracts/ExternalRangeMath.sol @@ -0,0 +1,60 @@ +// 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 . + +pragma solidity ^0.7.0; + +import "./RangeMath.sol"; + +/** + * @notice A contract-wrapper for Range Math. + * @dev Use this contract as an external replacement for RangeMath. + */ +contract ExternalRangeMath { + function calcOutGivenIn( + uint256 balanceIn, + uint256 weightIn, + uint256 balanceOut, + uint256 weightOut, + uint256 amountIn, + uint256 factBalance + ) external pure returns (uint256) { + return RangeMath._calcOutGivenIn(balanceIn, weightIn, balanceOut, weightOut, amountIn, factBalance); + } + + function calcBptOutGivenExactTokensIn( + uint256[] memory balances, + uint256[] memory amountsIn, + uint256 bptTotalSupply + ) external pure returns (uint256) { + return + RangeMath._calcBptOutGivenExactTokensIn( + balances, + amountsIn, + bptTotalSupply + ); + } + + function calcBptInGivenExactTokensOut( + uint256[] memory balances, + uint256[] memory amountsOut, + uint256 bptTotalSupply + ) external pure returns (uint256) { + return + RangeMath._calcBptInGivenExactTokensOut( + balances, + amountsOut, + bptTotalSupply + ); + } +} diff --git a/pkg/pool-range/contracts/RangeMath.sol b/pkg/pool-range/contracts/RangeMath.sol index 483efadf5..7e35304c9 100644 --- a/pkg/pool-range/contracts/RangeMath.sol +++ b/pkg/pool-range/contracts/RangeMath.sol @@ -59,14 +59,6 @@ library RangeMath { return bptTotalSupply.mulUp(ratioMin).divDown(FixedPoint.ONE); } - function _calcTokenInGivenExactBptOut( - uint256 factBalance, - uint256 bptAmountOut, - uint256 bptTotalSupply - ) internal pure returns (uint256) { - return bptTotalSupply.mulUp(bptAmountOut).divDown(factBalance); - } - function _calcBptInGivenExactTokensOut( uint256[] memory factBalances, uint256[] memory amountsOut, diff --git a/pkg/pool-range/contracts/RangePool.sol b/pkg/pool-range/contracts/RangePool.sol index 95a1050da..475a4ca9c 100644 --- a/pkg/pool-range/contracts/RangePool.sol +++ b/pkg/pool-range/contracts/RangePool.sol @@ -65,11 +65,14 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { uint256 internal immutable _normalizedWeight8; uint256 internal immutable _normalizedWeight9; + uint256[] internal _virtualBalances; + struct NewPoolParams { string name; string symbol; IERC20[] tokens; uint256[] normalizedWeights; + uint256[] virtualBalances; IRateProvider[] rateProviders; address[] assetManagers; uint256 swapFeePercentage; @@ -103,6 +106,7 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { { uint256 numTokens = params.tokens.length; InputHelpers.ensureInputLengthMatch(numTokens, params.normalizedWeights.length); + InputHelpers.ensureInputLengthMatch(numTokens, params.virtualBalances.length); _totalTokens = numTokens; @@ -113,6 +117,8 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { _require(normalizedWeight >= WeightedMath._MIN_WEIGHT, Errors.MIN_WEIGHT); normalizedSum = normalizedSum.add(normalizedWeight); + + _virtualBalances.push(params.virtualBalances[i]); } // Ensure that the normalized weights sum to ONE _require(normalizedSum == FixedPoint.ONE, Errors.NORMALIZED_WEIGHT_INVARIANT); @@ -215,6 +221,32 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { return _totalTokens; } + function _getVirtualBalance(IERC20 token) internal view virtual override returns (uint256) { + return _virtualBalances[_getTokenIndex(token)]; + } + + function _increaseVirtualBalance(IERC20 token, uint256 delta) internal virtual override { + uint256 tokenIdx = _getTokenIndex(token); + _virtualBalances[tokenIdx] = _virtualBalances[tokenIdx].add(delta); + } + + function _decreaseVirtualBalance(IERC20 token, uint256 delta) internal virtual override { + uint256 tokenIdx = _getTokenIndex(token); + _virtualBalances[tokenIdx] = _virtualBalances[tokenIdx].sub(delta); + } + + function _increaseVirtualBalances(uint256[] memory deltas) internal virtual override { + for (uint256 i = 0; i < _totalTokens; i++) { + _virtualBalances[i] = _virtualBalances[i].add(deltas[i]); + } + } + + function _decreaseVirtualBalances(uint256[] memory deltas) internal virtual override { + for (uint256 i = 0; i < _totalTokens; i++) { + _virtualBalances[i] = _virtualBalances[i].sub(deltas[i]); + } + } + /** * @dev Returns the scaling factor for one of the Pool's tokens. Reverts if `token` is not a token registered by the * Pool. diff --git a/pkg/pool-range/contracts/RangePoolFactory.sol b/pkg/pool-range/contracts/RangePoolFactory.sol new file mode 100644 index 000000000..ac4316810 --- /dev/null +++ b/pkg/pool-range/contracts/RangePoolFactory.sol @@ -0,0 +1,80 @@ +// 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 . + +pragma solidity ^0.7.0; +pragma experimental ABIEncoderV2; + +import "@balancer-labs/v2-interfaces/contracts/vault/IVault.sol"; + +import "@balancer-labs/v2-pool-utils/contracts/factories/BasePoolFactory.sol"; + +import "./RangePool.sol"; + +contract RangePoolFactory is BasePoolFactory { + constructor( + IVault vault, + IProtocolFeePercentagesProvider protocolFeeProvider, + uint256 initialPauseWindowDuration, + uint256 bufferPeriodDuration + ) + BasePoolFactory( + vault, + protocolFeeProvider, + initialPauseWindowDuration, + bufferPeriodDuration, + type(RangePool).creationCode + ) + { + // solhint-disable-previous-line no-empty-blocks + } + + /** + * @dev Deploys a new `WeightedPool`. + */ + function create( + string memory name, + string memory symbol, + IERC20[] memory tokens, + uint256[] memory normalizedWeights, + uint256[] memory virtualBalances, + IRateProvider[] memory rateProviders, + uint256 swapFeePercentage, + address owner, + bytes32 salt + ) external returns (address) { + (uint256 pauseWindowDuration, uint256 bufferPeriodDuration) = getPauseConfiguration(); + + return + _create( + abi.encode( + RangePool.NewPoolParams({ + name: name, + symbol: symbol, + tokens: tokens, + normalizedWeights: normalizedWeights, + virtualBalances: virtualBalances, + rateProviders: rateProviders, + assetManagers: new address[](tokens.length), // Don't allow asset managers, + swapFeePercentage: swapFeePercentage + }), + getVault(), + getProtocolFeePercentagesProvider(), + pauseWindowDuration, + bufferPeriodDuration, + owner + ), + salt + ); + } +} From 5ca94562189cab7588ec66fc914605a0fe509596 Mon Sep 17 00:00:00 2001 From: dcpp Date: Tue, 2 Sep 2025 13:02:25 +0300 Subject: [PATCH 03/13] Update RangePoolProtocolFees --- .../contracts/RangePoolProtocolFees.sol | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/pool-range/contracts/RangePoolProtocolFees.sol b/pkg/pool-range/contracts/RangePoolProtocolFees.sol index 251ec6eba..70e1556bc 100644 --- a/pkg/pool-range/contracts/RangePoolProtocolFees.sol +++ b/pkg/pool-range/contracts/RangePoolProtocolFees.sol @@ -34,6 +34,8 @@ abstract contract RangePoolProtocolFees is BaseRangePool, ProtocolFeeCache, IRat IRateProvider internal immutable _rateProvider5; IRateProvider internal immutable _rateProvider6; IRateProvider internal immutable _rateProvider7; + IRateProvider internal immutable _rateProvider8; + IRateProvider internal immutable _rateProvider9; bool internal immutable _exemptFromYieldFees; @@ -56,7 +58,7 @@ abstract contract RangePoolProtocolFees is BaseRangePool, ProtocolFeeCache, IRat event ATHRateProductUpdated(uint256 oldATHRateProduct, uint256 newATHRateProduct); constructor(uint256 numTokens, IRateProvider[] memory rateProviders) { - _require(numTokens <= 8, Errors.MAX_TOKENS); + _require(numTokens <= 10, Errors.MAX_TOKENS); InputHelpers.ensureInputLengthMatch(numTokens, rateProviders.length); _exemptFromYieldFees = _getYieldFeeExemption(rateProviders); @@ -69,6 +71,8 @@ abstract contract RangePoolProtocolFees is BaseRangePool, ProtocolFeeCache, IRat _rateProvider5 = numTokens > 5 ? rateProviders[5] : IRateProvider(0); _rateProvider6 = numTokens > 6 ? rateProviders[6] : IRateProvider(0); _rateProvider7 = numTokens > 7 ? rateProviders[7] : IRateProvider(0); + _rateProvider8 = numTokens > 8 ? rateProviders[8] : IRateProvider(0); + _rateProvider9 = numTokens > 9 ? rateProviders[9] : IRateProvider(0); } function _getYieldFeeExemption(IRateProvider[] memory rateProviders) internal pure returns (bool) { @@ -119,6 +123,8 @@ abstract contract RangePoolProtocolFees is BaseRangePool, ProtocolFeeCache, IRat if (totalTokens > 5) { providers[5] = _rateProvider5; } else { return providers; } if (totalTokens > 6) { providers[6] = _rateProvider6; } else { return providers; } if (totalTokens > 7) { providers[7] = _rateProvider7; } else { return providers; } + if (totalTokens > 8) { providers[8] = _rateProvider8; } else { return providers; } + if (totalTokens > 9) { providers[9] = _rateProvider9; } else { return providers; } } return providers; @@ -343,6 +349,18 @@ abstract contract RangePoolProtocolFees is BaseRangePool, ProtocolFeeCache, IRat } if (totalTokens > 7) { rateProduct = rateProduct.mulDown(_getRateFactor(normalizedWeights[7], _rateProvider7)); + } else { + return rateProduct; + } + if (totalTokens > 8) { + rateProduct = rateProduct.mulDown(_getRateFactor(normalizedWeights[8], _rateProvider8)); + } else { + return rateProduct; + } + if (totalTokens > 9) { + rateProduct = rateProduct.mulDown(_getRateFactor(normalizedWeights[9], _rateProvider9)); + } else { + return rateProduct; } return rateProduct; From eb1812f7a3976031b900032532a4d62675709f96 Mon Sep 17 00:00:00 2001 From: dcpp Date: Tue, 2 Sep 2025 19:19:07 +0300 Subject: [PATCH 04/13] Start test tooling --- pkg/pool-range/contracts/BaseRangePool.sol | 9 + pkg/pool-range/contracts/RangeMath.sol | 6 +- pkg/pool-range/contracts/RangePool.sol | 4 + pkg/pool-range/hardhat.config.ts | 2 +- pkg/pool-range/test/BaseRangePool.behavior.ts | 750 ++++++++++++++++++ pkg/pool-range/test/BaseRangePool.test.ts | 36 + pkg/pool-range/test/helpers/BaseRangePool.ts | 512 ++++++++++++ pkg/pool-range/test/helpers/RangePool.ts | 115 +++ pkg/pool-range/test/helpers/TypesConverter.ts | 66 ++ pkg/pool-range/test/helpers/contract.ts | 51 ++ pkg/pool-range/test/helpers/types.ts | 171 ++++ 11 files changed, 1719 insertions(+), 3 deletions(-) create mode 100644 pkg/pool-range/test/BaseRangePool.behavior.ts create mode 100644 pkg/pool-range/test/BaseRangePool.test.ts create mode 100644 pkg/pool-range/test/helpers/BaseRangePool.ts create mode 100644 pkg/pool-range/test/helpers/RangePool.ts create mode 100644 pkg/pool-range/test/helpers/TypesConverter.ts create mode 100644 pkg/pool-range/test/helpers/contract.ts create mode 100644 pkg/pool-range/test/helpers/types.ts diff --git a/pkg/pool-range/contracts/BaseRangePool.sol b/pkg/pool-range/contracts/BaseRangePool.sol index 1952bdb9d..4bda77373 100644 --- a/pkg/pool-range/contracts/BaseRangePool.sol +++ b/pkg/pool-range/contracts/BaseRangePool.sol @@ -87,6 +87,11 @@ abstract contract BaseRangePool is BaseGeneralPool { */ function _getNormalizedWeights() internal view virtual returns (uint256[] memory); + /** + * @dev Returns the virtual balance of `token`. + */ + function _getVirtualBalances() internal view virtual returns (uint256[] memory); + /** * @dev Returns the virtual balance of `token`. */ @@ -141,6 +146,10 @@ abstract contract BaseRangePool is BaseGeneralPool { return _getNormalizedWeights(); } + function getVirtualBalances() external view returns (uint256[] memory) { + return _getVirtualBalances(); + } + // Base Pool handlers // Swap diff --git a/pkg/pool-range/contracts/RangeMath.sol b/pkg/pool-range/contracts/RangeMath.sol index 7e35304c9..d9f9bd2c6 100644 --- a/pkg/pool-range/contracts/RangeMath.sol +++ b/pkg/pool-range/contracts/RangeMath.sol @@ -53,7 +53,8 @@ library RangeMath { uint256 ratioMin = amountsIn[0].mulUp(FixedPoint.ONE).divDown(factBalances[0]); uint256 i = 1; while (i < factBalances.length && ratioMin > 0) { - ratioMin = Math.min(ratioMin, amountsIn[0].mulUp(FixedPoint.ONE).divDown(factBalances[0])); + ratioMin = Math.min(ratioMin, amountsIn[i].mulUp(FixedPoint.ONE).divDown(factBalances[i])); + i++; } return bptTotalSupply.mulUp(ratioMin).divDown(FixedPoint.ONE); @@ -67,7 +68,8 @@ library RangeMath { uint256 ratioMin = amountsOut[0].mulUp(FixedPoint.ONE).divDown(factBalances[0]); uint256 i = 1; while (i < factBalances.length && ratioMin > 0) { - ratioMin = Math.min(ratioMin, amountsOut[0].mulUp(FixedPoint.ONE).divDown(factBalances[0])); + ratioMin = Math.min(ratioMin, amountsOut[i].mulUp(FixedPoint.ONE).divDown(factBalances[i])); + i++; } return bptTotalSupply.mulUp(ratioMin).divDown(FixedPoint.ONE); diff --git a/pkg/pool-range/contracts/RangePool.sol b/pkg/pool-range/contracts/RangePool.sol index 475a4ca9c..2325f0f84 100644 --- a/pkg/pool-range/contracts/RangePool.sol +++ b/pkg/pool-range/contracts/RangePool.sol @@ -221,6 +221,10 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { return _totalTokens; } + function _getVirtualBalances() internal view virtual override returns (uint256[] memory) { + return _virtualBalances; + } + function _getVirtualBalance(IERC20 token) internal view virtual override returns (uint256) { return _virtualBalances[_getTokenIndex(token)]; } diff --git a/pkg/pool-range/hardhat.config.ts b/pkg/pool-range/hardhat.config.ts index dff1620d7..dddadaacd 100644 --- a/pkg/pool-range/hardhat.config.ts +++ b/pkg/pool-range/hardhat.config.ts @@ -25,7 +25,7 @@ export default { settings: { optimizer: { enabled: true, - runs: 9999, + runs: 1000, }, }, }, diff --git a/pkg/pool-range/test/BaseRangePool.behavior.ts b/pkg/pool-range/test/BaseRangePool.behavior.ts new file mode 100644 index 000000000..3e426fe58 --- /dev/null +++ b/pkg/pool-range/test/BaseRangePool.behavior.ts @@ -0,0 +1,750 @@ +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address'; +import { PoolSpecialization, SwapKind } from '@balancer-labs/balancer-js'; +import { BigNumberish, bn, fp, fpMul, pct } from '@balancer-labs/v2-helpers/src/numbers'; + +import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; +import { RawRangePoolDeployment } from './helpers/types'; +import { ZERO_ADDRESS } from '@balancer-labs/v2-helpers/src/constants'; +import { sharedBeforeEach } from '@balancer-labs/v2-common/sharedBeforeEach'; +import { expectBalanceChange } from '@balancer-labs/v2-helpers/src/test/tokenBalance'; +import Vault from '@balancer-labs/v2-helpers/src/models/vault/Vault'; +import BaseRangePool from './helpers/BaseRangePool'; +import RangePool from './helpers/RangePool'; + +export function itBehavesAsRangePool(numberOfTokens: number): void { + const POOL_SWAP_FEE_PERCENTAGE = fp(0.01); + const WEIGHTS = [fp(30), fp(70), fp(5), fp(5)]; + const VIRTUAL_BALANCES = [fp(0), fp(0), fp(0), fp(0)]; + const INITIAL_BALANCES = [fp(0.9), fp(1.8), fp(2.7), fp(3.6)]; + const GENERAL_POOL_ONSWAP = + 'onSwap((uint8,address,address,uint256,bytes32,uint256,address,address,bytes),uint256[],uint256,uint256)'; + + let recipient: SignerWithAddress, other: SignerWithAddress, lp: SignerWithAddress; + let vault: Vault; + let pool: BaseRangePool, allTokens: TokenList, tokens: TokenList; + + const ZEROS = Array(numberOfTokens).fill(bn(0)); + const weights: BigNumberish[] = WEIGHTS.slice(0, numberOfTokens); + const virtualBalances: BigNumberish[] = VIRTUAL_BALANCES.slice(0, numberOfTokens); + const initialBalances = INITIAL_BALANCES.slice(0, numberOfTokens); + + async function deployPool(params: RawRangePoolDeployment = {}): Promise { + pool = await RangePool.create({ + vault, + tokens, + weights, + virtualBalances, + swapFeePercentage: POOL_SWAP_FEE_PERCENTAGE, + ...params, + }); + } + + before('setup signers', async () => { + [, lp, recipient, other] = await ethers.getSigners(); + }); + + sharedBeforeEach('deploy tokens and vault', async () => { + vault = await Vault.create(); + + const tokenAmounts = fp(100); + allTokens = await TokenList.create(['MKR', 'DAI', 'SNX', 'BAT', 'GRT'], { sorted: true }); + await allTokens.mint({ to: lp, amount: tokenAmounts }); + await allTokens.approve({ to: vault.address, from: lp, amount: tokenAmounts }); + }); + + beforeEach('define pool tokens', () => { + tokens = allTokens.subset(numberOfTokens); + }); + + describe('creation', () => { + context('when the creation succeeds', () => { + sharedBeforeEach('deploy pool from factory', async () => { + await deployPool({ fromFactory: true }); + }); + + it('sets the vault', async () => { + expect(await pool.getVault()).to.equal(pool.vault.address); + }); + + it('uses the corresponding specialization', async () => { + const expectedSpecialization = + numberOfTokens == 2 ? PoolSpecialization.TwoTokenPool : PoolSpecialization.MinimalSwapInfoPool; + + const { address, specialization } = await pool.getRegisteredInfo(); + expect(address).to.equal(pool.address); + expect(specialization).to.equal(expectedSpecialization); + }); + + it('registers tokens in the vault', async () => { + const poolTokens = await pool.getTokens(); + + expect(poolTokens.tokens).to.have.members(tokens.addresses); + expect(poolTokens.balances).to.be.zeros; + }); + + it('starts with no BPT', async () => { + expect(await pool.totalSupply()).to.be.equal(0); + }); + + it('sets the asset managers to zero', async () => { + await tokens.asyncEach(async (token) => { + const info = await pool.getTokenInfo(token); + expect(info.assetManager).to.equal(ZERO_ADDRESS); + }); + }); + + it('sets swap fee', async () => { + expect(await pool.getSwapFeePercentage()).to.equal(POOL_SWAP_FEE_PERCENTAGE); + }); + + it('sets the name', async () => { + expect(await pool.name()).to.equal('Balancer Pool Token'); + }); + + it('sets the symbol', async () => { + expect(await pool.symbol()).to.equal('BPT'); + }); + + it('sets the decimals', async () => { + expect(await pool.decimals()).to.equal(18); + }); + }); + + context('when the creation fails', () => { + it('reverts if the number of tokens and weights do not match', async () => { + const badWeights = weights.slice(1); + + await expect(deployPool({ weights: badWeights })).to.be.revertedWith('INPUT_LENGTH_MISMATCH'); + }); + + it('reverts if there are repeated tokens', async () => { + const badTokens = new TokenList(Array(numberOfTokens).fill(tokens.first)); + + await expect(deployPool({ tokens: badTokens, fromFactory: true })).to.be.revertedWith('UNSORTED_ARRAY'); + }); + + it('reverts if the swap fee is too high', async () => { + const badSwapFeePercentage = fp(0.1).add(1); + + await expect(deployPool({ swapFeePercentage: badSwapFeePercentage })).to.be.revertedWith( + 'MAX_SWAP_FEE_PERCENTAGE' + ); + }); + + it('reverts if at least one weight is too low', async () => { + const badWeights = WEIGHTS.slice(0, numberOfTokens); + badWeights[0] = bn(99); + + await expect(deployPool({ weights: badWeights })).to.be.revertedWith('MIN_WEIGHT'); + }); + }); + }); + + describe('onJoinPool', () => { + function itJoins() { + it('fails if caller is not the vault', async () => { + await expect( + pool.instance.connect(lp).onJoinPool(pool.poolId, lp.address, other.address, [0], 0, 0, '0x') + ).to.be.revertedWith('CALLER_NOT_VAULT'); + }); + + it('fails if no user data', async () => { + await expect(pool.join({ data: '0x', from: lp })).to.be.revertedWith('Transaction reverted without a reason'); + }); + + it('fails if wrong user data', async () => { + const wrongUserData = ethers.utils.defaultAbiCoder.encode(['address'], [lp.address]); + + await expect(pool.join({ data: wrongUserData, from: lp })).to.be.revertedWith( + 'Transaction reverted without a reason' + ); + }); + + context('initialization', () => { + it('grants the n * invariant amount of BPT', async () => { + const invariant = await pool.estimateInvariant(initialBalances); + + const { amountsIn, dueProtocolFeeAmounts } = await pool.init({ recipient, initialBalances, from: lp }); + + // Amounts in should be the same as initial ones + expect(amountsIn).to.deep.equal(initialBalances); + + // Protocol fees should be zero + expect(dueProtocolFeeAmounts).to.be.zeros; + + // Initial balances should equal invariant + expect(await pool.balanceOf(recipient)).to.be.equalWithError(invariant.mul(numberOfTokens), 0.001); + }); + + it('fails if already initialized', async () => { + await pool.init({ recipient, initialBalances, from: lp }); + + await expect(pool.init({ initialBalances, from: lp })).to.be.revertedWith('UNHANDLED_JOIN_KIND'); + }); + + it('reverts if paused', async () => { + await pool.pause(); + + await expect(pool.init({ initialBalances, from: lp })).to.be.revertedWith('PAUSED'); + }); + }); + + context('join exact tokens in for BPT out', () => { + it('fails if not initialized', async () => { + await expect(pool.joinGivenIn({ recipient, amountsIn: initialBalances, from: lp })).to.be.revertedWith( + 'UNINITIALIZED' + ); + }); + + context('once initialized', () => { + let expectedBptOut: BigNumberish; + const amountsIn = ZEROS.map((n, i) => (i === 1 ? fp(0.1) : n)); + + sharedBeforeEach('initialize pool', async () => { + await pool.init({ recipient, initialBalances, from: lp }); + expectedBptOut = await pool.estimateBptOut(amountsIn, initialBalances); + }); + + it('grants BPT for exact tokens', async () => { + const previousBptBalance = await pool.balanceOf(recipient); + const minimumBptOut = pct(expectedBptOut, 0.99); + + const result = await pool.joinGivenIn({ amountsIn, minimumBptOut, recipient, from: lp }); + + // Amounts in should be the same as initial ones + expect(result.amountsIn).to.deep.equal(amountsIn); + + // Protocol fees should be zero + expect(result.dueProtocolFeeAmounts).to.be.zeros; + + // Make sure received BPT is close to what we expect + const currentBptBalance = await pool.balanceOf(recipient); + expect(currentBptBalance.sub(previousBptBalance)).to.be.equalWithError(expectedBptOut, 0.0001); + }); + + it('can tell how much BPT it will give in return', async () => { + const minimumBptOut = pct(expectedBptOut, 0.99); + + const result = await pool.queryJoinGivenIn({ amountsIn, minimumBptOut, from: lp }); + + expect(result.amountsIn).to.deep.equal(amountsIn); + expect(result.bptOut).to.be.equalWithError(expectedBptOut, 0.0001); + }); + + it('fails if not enough BPT', async () => { + // This call should fail because we are requesting minimum 1% more + const minimumBptOut = pct(expectedBptOut, 1.01); + + await expect(pool.joinGivenIn({ amountsIn, minimumBptOut, from: lp })).to.be.revertedWith( + 'BPT_OUT_MIN_AMOUNT' + ); + }); + + it('reverts if paused', async () => { + await pool.pause(); + + await expect(pool.joinGivenIn({ amountsIn })).to.be.revertedWith('PAUSED'); + }); + }); + }); + + context('join all tokens in for exact BPT out', () => { + it('fails if not initialized', async () => { + await expect(pool.joinAllGivenOut({ bptOut: fp(2), from: lp })).to.be.revertedWith('UNINITIALIZED'); + }); + + context('once initialized', () => { + sharedBeforeEach('initialize pool', async () => { + await pool.init({ recipient, initialBalances, from: lp }); + }); + + it('grants exact BPT for tokens in', async () => { + const previousBptBalance = await pool.balanceOf(recipient); + // We want to join for half the initial BPT supply, which will require half the initial balances + const bptOut = previousBptBalance.div(2); + + const expectedAmountsIn = initialBalances.map((balance) => balance.div(2)); + + const result = await pool.joinAllGivenOut({ recipient, bptOut, from: lp }); + + for (let i = 0; i < expectedAmountsIn.length; i++) { + expect(result.amountsIn[i]).to.be.equalWithError(expectedAmountsIn[i], 0.001); + } + + // Protocol fees should be zero + expect(result.dueProtocolFeeAmounts).to.be.zeros; + + // Make sure received BPT equals we expect (since bptOut is given) + const currentBptBalance = await pool.balanceOf(recipient); + expect(currentBptBalance.sub(previousBptBalance)).to.be.equal(bptOut); + }); + + it('can tell what token amounts it will have to receive', async () => { + const expectedAmountsIn = initialBalances.map((balance) => balance.div(2)); + const previousBptBalance = await pool.balanceOf(recipient); + // We want to join for half the initial BPT supply, which will require half the initial balances + const bptOut = previousBptBalance.div(2); + + const result = await pool.queryJoinAllGivenOut({ bptOut, from: lp }); + + expect(result.bptOut).to.be.equal(bptOut); + + for (let i = 0; i < expectedAmountsIn.length; i++) { + expect(result.amountsIn[i]).to.be.equalWithError(expectedAmountsIn[i], 0.001); + } + }); + + it('reverts if paused', async () => { + await pool.pause(); + + await expect(pool.joinAllGivenOut({ bptOut: fp(2) })).to.be.revertedWith('PAUSED'); + }); + }); + }); + } + + sharedBeforeEach('deploy pool', async () => { + await deployPool(); + }); + + context('when not in recovery mode', () => { + itJoins(); + }); + + context('when in recovery mode', () => { + sharedBeforeEach(async () => { + await pool.enableRecoveryMode(); + }); + + itJoins(); + }); + }); + + describe('onExitPool', () => { + let previousBptBalance: BigNumber; + + function itExits() { + it('fails if caller is not the vault', async () => { + await expect( + pool.instance.connect(lp).onExitPool(pool.poolId, recipient.address, other.address, [0], 0, 0, '0x') + ).to.be.revertedWith('CALLER_NOT_VAULT'); + }); + + it('fails if no user data', async () => { + await expect(pool.exit({ data: '0x' })).to.be.revertedWith('Transaction reverted without a reason'); + }); + + it('fails if wrong user data', async () => { + const wrongUserData = ethers.utils.defaultAbiCoder.encode(['address'], [lp.address]); + + await expect(pool.exit({ data: wrongUserData })).to.be.revertedWith('Transaction reverted without a reason'); + }); + + context('exit exact BPT in for all tokens out', () => { + it('grants all tokens for exact bpt', async () => { + // Exit with half of the BPT balance + const bptIn = previousBptBalance.div(2); + const expectedAmountsOut = initialBalances.map((balance) => balance.div(2)); + + const result = await pool.multiExitGivenIn({ from: lp, bptIn }); + + // Protocol fees should be zero + expect(result.dueProtocolFeeAmounts).to.be.zeros; + + // Balances are reduced by half because we are returning half of the BPT supply + expect(result.amountsOut).to.be.equalWithError(expectedAmountsOut, 0.001); + + // Current BPT balance should have been reduced by half + expect(await pool.balanceOf(lp)).to.be.equalWithError(bptIn, 0.001); + }); + + it('fully exit', async () => { + // The LP doesn't own all BPT, since some was locked. They will only be able to extract a (large) percentage + // of the Pool's balance: the rest remains there forever. + const totalBPT = await pool.totalSupply(); + const expectedAmountsOut = initialBalances.map((balance) => balance.mul(previousBptBalance).div(totalBPT)); + + const result = await pool.multiExitGivenIn({ from: lp, bptIn: previousBptBalance }); + + // Protocol fees should be zero + expect(result.dueProtocolFeeAmounts).to.be.zeros; + + // All balances are extracted + expect(result.amountsOut).to.be.lteWithError(expectedAmountsOut, 0.00001); + + // Current BPT balances should be zero due to full exit + expect(await pool.balanceOf(lp)).to.equal(0); + }); + + it('can tell what token amounts it will give in return', async () => { + const totalBPT = await pool.totalSupply(); + const expectedAmountsOut = initialBalances.map((balance) => balance.mul(previousBptBalance).div(totalBPT)); + + const result = await pool.queryMultiExitGivenIn({ bptIn: previousBptBalance }); + + expect(result.bptIn).to.equal(previousBptBalance); + expect(result.amountsOut).to.be.lteWithError(expectedAmountsOut, 0.00001); + }); + + it('reverts if paused', async () => { + await pool.pause(); + + const bptIn = previousBptBalance.div(2); + await expect(pool.multiExitGivenIn({ from: lp, bptIn })).to.be.revertedWith('PAUSED'); + }); + }); + + context('exit BPT in for exact tokens out', () => { + it('grants exact tokens for bpt', async () => { + // Request half of the token balances + const amountsOut = initialBalances.map((balance) => balance.div(2)); + const expectedBptIn = previousBptBalance.div(2); + const maximumBptIn = pct(expectedBptIn, 1.01); + + const result = await pool.exitGivenOut({ from: lp, amountsOut, maximumBptIn }); + + // Protocol fees should be zero + expect(result.dueProtocolFeeAmounts).to.be.zeros; + + // Token balances should been reduced as requested + expect(result.amountsOut).to.deep.equal(amountsOut); + + // BPT balance should have been reduced by half because we are returning half of the tokens + expect(await pool.balanceOf(lp)).to.be.equalWithError(previousBptBalance.div(2), 0.001); + }); + + it('can tell how much BPT it will have to receive', async () => { + const amountsOut = initialBalances.map((balance) => balance.div(2)); + const expectedBptIn = previousBptBalance.div(2); + const maximumBptIn = pct(expectedBptIn, 1.01); + + const result = await pool.queryExitGivenOut({ amountsOut, maximumBptIn }); + + expect(result.amountsOut).to.deep.equal(amountsOut); + expect(result.bptIn).to.be.equalWithError(previousBptBalance.div(2), 0.001); + }); + + it('fails if more BPT needed', async () => { + // Call should fail because we are requesting a max amount lower than the actual needed + const amountsOut = initialBalances; + const maximumBptIn = previousBptBalance.div(2); + + await expect(pool.exitGivenOut({ from: lp, amountsOut, maximumBptIn })).to.be.revertedWith( + 'BPT_IN_MAX_AMOUNT' + ); + }); + + it('reverts if paused', async () => { + await pool.pause(); + + const amountsOut = initialBalances; + await expect(pool.exitGivenOut({ from: lp, amountsOut })).to.be.revertedWith('PAUSED'); + }); + }); + } + + sharedBeforeEach('deploy and initialize pool', async () => { + await deployPool(); + await pool.init({ initialBalances, from: lp }); + previousBptBalance = await pool.balanceOf(lp); + }); + + context('when not in recovery mode', () => { + itExits(); + }); + + context('when in recovery mode', () => { + sharedBeforeEach(async () => { + await pool.enableRecoveryMode(); + }); + + itExits(); + }); + }); + + describe('onSwap', () => { + function itSwaps() { + context('given in', () => { + it('reverts if caller is not the vault', async () => { + await expect( + pool.instance[GENERAL_POOL_ONSWAP]( + { + kind: SwapKind.GivenIn, + tokenIn: tokens.first.address, + tokenOut: tokens.second.address, + amount: 0, + poolId: await pool.getPoolId(), + lastChangeBlock: 0, + from: lp.address, + to: other.address, + userData: '0x', + }, + [], + 0, + 0 + ) + ).to.be.revertedWith('CALLER_NOT_VAULT'); + }); + + it('calculates amount out', async () => { + const amount = fp(0.1); + const amountWithFees = fpMul(amount, POOL_SWAP_FEE_PERCENTAGE.add(fp(1))); + const expectedAmountOut = await pool.estimateGivenIn({ in: 1, out: 0, amount: amountWithFees }); + + const result = await pool.swapGivenIn({ in: 1, out: 0, amount: amountWithFees, from: lp, recipient }); + + expect(result.amount).to.be.equalWithError(expectedAmountOut, 0.01); + }); + + it('calculates max amount out', async () => { + const maxAmountIn = await pool.getMaxIn(1); + const maxAmountInWithFees = fpMul(maxAmountIn, POOL_SWAP_FEE_PERCENTAGE.add(fp(1))); + const expectedAmountOut = await pool.estimateGivenIn({ in: 1, out: 0, amount: maxAmountInWithFees }); + + const result = await pool.swapGivenIn({ in: 1, out: 0, amount: maxAmountInWithFees, from: lp, recipient }); + + expect(result.amount).to.be.equalWithError(expectedAmountOut, 0.05); + }); + + it('reverts if token in exceeds max in ratio', async () => { + const maxAmountIn = await pool.getMaxIn(1); + const maxAmountInWithFees = fpMul(maxAmountIn, POOL_SWAP_FEE_PERCENTAGE.add(fp(1))); + + const amount = maxAmountInWithFees.add(fp(1)); + await expect(pool.swapGivenIn({ in: 1, out: 0, amount, from: lp })).to.be.revertedWith('MAX_IN_RATIO'); + }); + + it('reverts if token in is not in the pool', async () => { + await expect(pool.swapGivenIn({ in: allTokens.GRT, out: 0, amount: 1, from: lp })).to.be.revertedWith( + 'TOKEN_NOT_REGISTERED' + ); + }); + + it('reverts if token out is not in the pool', async () => { + await expect(pool.swapGivenIn({ in: 1, out: allTokens.GRT, amount: 1, from: lp })).to.be.revertedWith( + 'TOKEN_NOT_REGISTERED' + ); + }); + + it('reverts if paused', async () => { + await pool.pause(); + + await expect(pool.swapGivenIn({ in: 1, out: 0, amount: 1, from: lp })).to.be.revertedWith('PAUSED'); + }); + }); + + context('given out', () => { + it('reverts if caller is not the vault', async () => { + await expect( + pool.instance[GENERAL_POOL_ONSWAP]( + { + kind: SwapKind.GivenOut, + tokenIn: tokens.first.address, + tokenOut: tokens.second.address, + amount: 0, + poolId: await pool.getPoolId(), + lastChangeBlock: 0, + from: lp.address, + to: other.address, + userData: '0x', + }, + [], + 0, + 0 + ) + ).to.be.revertedWith('CALLER_NOT_VAULT'); + }); + + it('calculates amount in', async () => { + const amount = fp(0.1); + const expectedAmountIn = await pool.estimateGivenOut({ in: 1, out: 0, amount }); + + const result = await pool.swapGivenOut({ in: 1, out: 0, amount, from: lp, recipient }); + + expect(result.amount).to.be.equalWithError(expectedAmountIn, 0.1); + }); + + it('calculates max amount in', async () => { + const amount = await pool.getMaxOut(0); + const expectedAmountIn = await pool.estimateGivenOut({ in: 1, out: 0, amount }); + + const result = await pool.swapGivenOut({ in: 1, out: 0, amount, from: lp, recipient }); + + expect(result.amount).to.be.equalWithError(expectedAmountIn, 0.1); + }); + + it('reverts if token in exceeds max out ratio', async () => { + const amount = (await pool.getMaxOut(0)).add(2); + + await expect(pool.swapGivenOut({ in: 1, out: 0, amount, from: lp })).to.be.revertedWith('MAX_OUT_RATIO'); + }); + + it('reverts if token in is not in the pool when given out', async () => { + await expect(pool.swapGivenOut({ in: allTokens.GRT, out: 0, amount: 1, from: lp })).to.be.revertedWith( + 'TOKEN_NOT_REGISTERED' + ); + }); + + it('reverts if token out is not in the pool', async () => { + await expect(pool.swapGivenOut({ in: 1, out: allTokens.GRT, amount: 1, from: lp })).to.be.revertedWith( + 'TOKEN_NOT_REGISTERED' + ); + }); + + it('reverts if paused', async () => { + await pool.pause(); + + await expect(pool.swapGivenOut({ in: 1, out: 0, amount: 1, from: lp })).to.be.revertedWith('PAUSED'); + }); + }); + } + + sharedBeforeEach('deploy and join pool', async () => { + await deployPool(); + await pool.init({ initialBalances, from: lp }); + }); + + context('when not in recovery mode', () => { + itSwaps(); + }); + + context('when in recovery mode', () => { + sharedBeforeEach(async () => { + await pool.enableRecoveryMode(); + }); + + itSwaps(); + }); + }); + + describe('recovery mode', () => { + sharedBeforeEach('deploy pool and enter recovery mode', async () => { + await deployPool(); + await pool.init({ initialBalances, from: lp }); + await pool.enableRecoveryMode(); + }); + + function itExitsViaRecoveryModeCorrectly() { + it('the recovery mode exit can be used', async () => { + const preExitBPT = await pool.balanceOf(lp.address); + const exitBPT = preExitBPT.div(3); + + // The sole BPT holder is the initial LP, so they own the initial balances + const expectedChanges = tokens.reduce( + (changes, token, i) => ({ ...changes, [token.symbol]: ['very-near', initialBalances[i].div(3)] }), + {} + ); + + await expectBalanceChange( + () => + pool.recoveryModeExit({ + from: lp, + bptIn: exitBPT, + }), + tokens, + { account: lp, changes: expectedChanges } + ); + + // Exit BPT was burned + const afterExitBalance = await pool.balanceOf(lp.address); + expect(afterExitBalance).to.equal(preExitBPT.sub(exitBPT)); + }); + } + + itExitsViaRecoveryModeCorrectly(); + + context('when paused', () => { + sharedBeforeEach('pause pool', async () => { + await pool.pause(); + }); + + itExitsViaRecoveryModeCorrectly(); + }); + }); + + describe('protocol swap fees', () => { + const protocolFeePercentage = fp(0.1); // 10 % + + sharedBeforeEach('deploy and join pool', async () => { + // We will use a mock vault for this one since we'll need to manipulate balances. + await deployPool({ vault: await Vault.create({ mocked: true }) }); + await pool.init({ initialBalances, from: lp, protocolFeePercentage }); + }); + + context('without balance changes', () => { + it('no protocol fees on joins and exits', async () => { + let joinResult = await pool.joinGivenIn({ from: lp, amountsIn: fp(100), protocolFeePercentage }); + expect(joinResult.dueProtocolFeeAmounts).to.be.zeros; + + joinResult = await pool.joinGivenOut({ from: lp, bptOut: fp(1), token: 0, protocolFeePercentage }); + expect(joinResult.dueProtocolFeeAmounts).to.be.zeros; + + joinResult = await pool.joinAllGivenOut({ from: lp, bptOut: fp(0.1) }); + expect(joinResult.dueProtocolFeeAmounts).to.be.zeros; + + let exitResult = await pool.singleExitGivenIn({ from: lp, bptIn: fp(10), token: 0, protocolFeePercentage }); + expect(exitResult.dueProtocolFeeAmounts).to.be.zeros; + + exitResult = await pool.multiExitGivenIn({ from: lp, bptIn: fp(10), protocolFeePercentage }); + expect(exitResult.dueProtocolFeeAmounts).to.be.zeros; + + joinResult = await pool.joinGivenIn({ from: lp, amountsIn: fp(10), protocolFeePercentage }); + expect(joinResult.dueProtocolFeeAmounts).to.be.zeros; + + exitResult = await pool.exitGivenOut({ from: lp, amountsOut: fp(10), protocolFeePercentage }); + expect(exitResult.dueProtocolFeeAmounts).to.be.zeros; + }); + }); + + context('with balance changes', () => { + let currentBalances: BigNumber[]; + + sharedBeforeEach('simulate doubled initial balances', async () => { + // 4/3 of the initial balances + currentBalances = initialBalances.map((balance) => balance.mul(4).div(3)); + }); + + it('no protocol fees on join exact tokens in for BPT out', async () => { + const result = await pool.joinGivenIn({ from: lp, amountsIn: fp(1), currentBalances, protocolFeePercentage }); + expect(result.dueProtocolFeeAmounts).to.be.zeros; + }); + + it('no protocol fees on exit exact BPT in for one token out', async () => { + const result = await pool.singleExitGivenIn({ + from: lp, + bptIn: fp(0.5), + token: 0, + currentBalances, + protocolFeePercentage, + }); + + expect(result.dueProtocolFeeAmounts).to.be.zeros; + }); + + it('no protocol fees on exit exact BPT in for all tokens out', async () => { + const result = await pool.multiExitGivenIn({ + from: lp, + bptIn: fp(1), + currentBalances, + protocolFeePercentage, + }); + + expect(result.dueProtocolFeeAmounts).to.be.zeros; + }); + + it('no protocol fees on exit BPT In for exact tokens out', async () => { + const result = await pool.exitGivenOut({ + from: lp, + amountsOut: fp(1), + currentBalances, + protocolFeePercentage, + }); + + expect(result.dueProtocolFeeAmounts).to.be.zeros; + }); + }); + }); +} diff --git a/pkg/pool-range/test/BaseRangePool.test.ts b/pkg/pool-range/test/BaseRangePool.test.ts new file mode 100644 index 000000000..4f91dd3af --- /dev/null +++ b/pkg/pool-range/test/BaseRangePool.test.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai'; +import { fp } from '@balancer-labs/v2-helpers/src/numbers'; + +import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; +import RangePool from './helpers/RangePool'; + +import { itBehavesAsRangePool } from './BaseRangePool.behavior'; + +describe('BaseRangePool', function () { + context('for a 1 token pool', () => { + it('reverts if there is a single token', async () => { + const tokens = await TokenList.create(1); + const weights = [fp(1)]; + + await expect(RangePool.create({ tokens, weights })).to.be.revertedWith('MIN_TOKENS'); + }); + }); + + context('for a 2 token pool', () => { + itBehavesAsRangePool(2); + }); + + /*context('for a 3 token pool', () => { + itBehavesAsRangePool(3); + }); + + context('for a too-many token pool', () => { + it('reverts if there are too many tokens', async () => { + // The maximum number of tokens is 20 + const tokens = await TokenList.create(21); + const weights = new Array(21).fill(fp(1)); + + await expect(RangePool.create({ tokens, weights })).to.be.revertedWith('MAX_TOKENS'); + }); + });*/ +}); diff --git a/pkg/pool-range/test/helpers/BaseRangePool.ts b/pkg/pool-range/test/helpers/BaseRangePool.ts new file mode 100644 index 000000000..43b3d383d --- /dev/null +++ b/pkg/pool-range/test/helpers/BaseRangePool.ts @@ -0,0 +1,512 @@ +import { BigNumber, Contract, ContractFunction, ContractReceipt, ContractTransaction } from 'ethers'; +import { BigNumberish, bn, fp, fromFp, fpMul } from '@balancer-labs/v2-helpers/src/numbers'; +import { MAX_UINT256, ZERO_ADDRESS } from '@balancer-labs/v2-helpers/src/constants'; +import * as expectEvent from '@balancer-labs/v2-helpers/src/test/expectEvent'; +import Vault from '@balancer-labs/v2-helpers/src/models/vault/Vault'; +import Token from '@balancer-labs/v2-helpers/src/models/tokens/Token'; +import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; +import TypesConverter from '@balancer-labs/v2-helpers/src/models/types/TypesConverter'; +import { GeneralSwap } from '@balancer-labs/v2-helpers/src/models/vault/types'; +import { + JoinExitRangePool, + InitRangePool, + JoinGivenInRangePool, + JoinGivenOutRangePool, + JoinAllGivenOutRangePool, + JoinResult, + ExitResult, + SwapResult, + SingleExitGivenInRangePool, + MultiExitGivenInRangePool, + ExitGivenOutRangePool, + SwapRangePool, + ExitQueryResult, + JoinQueryResult, + PoolQueryResult, + GradualWeightUpdateParams, +} from './types'; +import { + calculateInvariant, + calcTokenInGivenExactBptOut, + calcTokenOutGivenExactBptIn, + calcOutGivenIn, + calcInGivenOut, +} from '@balancer-labs/v2-helpers/src/models/pools/weighted/math'; + +import { SwapKind, WeightedPoolEncoder } from '@balancer-labs/balancer-js'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import BasePool from '@balancer-labs/v2-helpers/src/models/pools/base/BasePool'; +import { Account } from '@balancer-labs/v2-helpers/src/models/types/types'; + +const MAX_IN_RATIO = fp(0.3); +const MAX_OUT_RATIO = fp(0.3); +const MAX_INVARIANT_RATIO = fp(3); +const MIN_INVARIANT_RATIO = fp(0.7); + +export default class BaseRangePool extends BasePool { + weights: BigNumberish[]; + vbalances: BigNumberish[]; + + constructor( + instance: Contract, + poolId: string, + vault: Vault, + tokens: TokenList, + weights: BigNumberish[], + virtualBalances: BigNumberish[], + swapFeePercentage: BigNumberish, + owner?: Account + ) { + super(instance, poolId, vault, tokens, swapFeePercentage, owner); + + this.weights = weights; + this.vbalances = virtualBalances; + } + + get normalizedWeights(): BigNumberish[] { + return this.weights; + } + + get virtualBalances(): BigNumberish[] { + return this.vbalances; + } + + async getLastPostJoinExitInvariant(): Promise { + return this.instance.getLastPostJoinExitInvariant(); + } + + async getMaxInvariantDecrease(): Promise { + const supply = await this.totalSupply(); + return supply.sub(fpMul(MIN_INVARIANT_RATIO, supply)); + } + + async getMaxInvariantIncrease(): Promise { + const supply = await this.totalSupply(); + return fpMul(MAX_INVARIANT_RATIO, supply).sub(supply); + } + + async getMaxIn(tokenIndex: number, currentBalances?: BigNumber[]): Promise { + if (!currentBalances) currentBalances = await this.getBalances(); + return fpMul(currentBalances[tokenIndex], MAX_IN_RATIO); + } + + async getMaxOut(tokenIndex: number, currentBalances?: BigNumber[]): Promise { + if (!currentBalances) currentBalances = await this.getBalances(); + return fpMul(currentBalances[tokenIndex], MAX_OUT_RATIO); + } + + async getNormalizedWeights(): Promise { + return this.instance.getNormalizedWeights(); + } + + async estimateInvariant(currentBalances?: BigNumberish[]): Promise { + if (!currentBalances) currentBalances = await this.getBalances(); + const scalingFactors = await this.getScalingFactors(); + + return calculateInvariant( + currentBalances.map((x, i) => fpMul(x, scalingFactors[i])), + this.weights + ); + } + + async estimateGivenIn(params: SwapRangePool, currentBalances?: BigNumberish[]): Promise { + if (!currentBalances) currentBalances = await this.getBalances(); + const [tokenIn, tokenOut] = this.tokens.indicesOfTwoTokens(params.in, params.out); + + return bn( + calcOutGivenIn( + currentBalances[tokenIn], + this.weights[tokenIn], + currentBalances[tokenOut], + this.weights[tokenOut], + params.amount + ) + ); + } + + async estimateGivenOut(params: SwapRangePool, currentBalances?: BigNumberish[]): Promise { + if (!currentBalances) currentBalances = await this.getBalances(); + const [tokenIn, tokenOut] = this.tokens.indicesOfTwoTokens(params.in, params.out); + + return bn( + calcInGivenOut( + currentBalances[tokenIn], + this.weights[tokenIn], + currentBalances[tokenOut], + this.weights[tokenOut], + params.amount + ) + ); + } + + async calcBptOutGivenExactTokensIn( + fpBalances: BigNumberish[], + fpAmountsIn: BigNumberish[], + fpBptTotalSupply: BigNumberish + ): Promise { + const balances = fpBalances.map(fromFp); + const amountsIn = fpAmountsIn.map(fromFp); + const bptTotalSupply = fromFp(fpBptTotalSupply); + + let ratioMin = amountsIn[0].div(balances[0]); + let i = 1; + while (i < balances.length && ratioMin.gt(0)) { + const tmp = amountsIn[i].div(balances[i]); + if (tmp < ratioMin) ratioMin = tmp; + i++; + } + return fp(bptTotalSupply.mul(ratioMin)); + } + + async estimateBptOut( + amountsIn: BigNumberish[], + currentBalances?: BigNumberish[], + supply?: BigNumberish + ): Promise { + if (!supply) supply = await this.totalSupply(); + if (!currentBalances) currentBalances = await this.getBalances(); + return this.calcBptOutGivenExactTokensIn(currentBalances, amountsIn, supply); + } + + async estimateTokenIn( + token: number | Token, + bptOut: BigNumberish, + currentBalances?: BigNumberish[], + supply?: BigNumberish + ): Promise { + if (!supply) supply = await this.totalSupply(); + if (!currentBalances) currentBalances = await this.getBalances(); + const tokenIndex = this.tokens.indexOf(token); + return calcTokenInGivenExactBptOut( + tokenIndex, + currentBalances, + this.weights, + bptOut, + supply, + this.swapFeePercentage + ); + } + + async estimateTokenOut( + token: number | Token, + bptIn: BigNumberish, + currentBalances?: BigNumberish[], + supply?: BigNumberish + ): Promise { + if (!supply) supply = await this.totalSupply(); + if (!currentBalances) currentBalances = await this.getBalances(); + const tokenIndex = this.tokens.indexOf(token); + return calcTokenOutGivenExactBptIn( + tokenIndex, + currentBalances, + this.weights, + bptIn, + supply, + this.swapFeePercentage + ); + } + + async swapGivenIn(params: SwapRangePool): Promise { + return this.swap(await this._buildSwapParams(SwapKind.GivenIn, params)); + } + + async swapGivenOut(params: SwapRangePool): Promise { + return this.swap(await this._buildSwapParams(SwapKind.GivenOut, params)); + } + + async updateProtocolFeePercentageCache(): Promise { + return this.instance.updateProtocolFeePercentageCache(); + } + + async swap(params: GeneralSwap): Promise { + let receipt: ContractReceipt; + if (this.vault.mocked) { + const tx = await this.vault.generalSwap(params); + receipt = await tx.wait(); + } else { + if (!params.from) throw new Error('No signer provided'); + const tx = await this.vault.instance.connect(params.from).swap( + { + poolId: params.poolId, + kind: params.kind, + assetIn: params.tokenIn, + assetOut: params.tokenOut, + amount: params.amount, + userData: params.data, + }, + { + sender: TypesConverter.toAddress(params.from), + recipient: TypesConverter.toAddress(params.to) ?? ZERO_ADDRESS, + fromInternalBalance: false, + toInternalBalance: false, + }, + params.kind == 0 ? 0 : MAX_UINT256, + MAX_UINT256 + ); + receipt = await tx.wait(); + } + const { amountIn, amountOut } = expectEvent.inReceipt(receipt, 'Swap').args; + const amount = params.kind == SwapKind.GivenIn ? amountOut : amountIn; + + return { amount, receipt }; + } + + async init(params: InitRangePool): Promise { + return this.join(this._buildInitParams(params)); + } + + async joinGivenIn(params: JoinGivenInRangePool): Promise { + return this.join(this._buildJoinGivenInParams(params)); + } + + async queryJoinGivenIn(params: JoinGivenInRangePool): Promise { + return this.queryJoin(this._buildJoinGivenInParams(params)); + } + + async joinGivenOut(params: JoinGivenOutRangePool): Promise { + return this.join(this._buildJoinGivenOutParams(params)); + } + + async queryJoinGivenOut(params: JoinGivenOutRangePool): Promise { + return this.queryJoin(this._buildJoinGivenOutParams(params)); + } + + async joinAllGivenOut(params: JoinAllGivenOutRangePool): Promise { + return this.join(this._buildJoinAllGivenOutParams(params)); + } + + async queryJoinAllGivenOut(params: JoinAllGivenOutRangePool): Promise { + return this.queryJoin(this._buildJoinAllGivenOutParams(params)); + } + + async exitGivenOut(params: ExitGivenOutRangePool): Promise { + return this.exit(this._buildExitGivenOutParams(params)); + } + + async queryExitGivenOut(params: ExitGivenOutRangePool): Promise { + return this.queryExit(this._buildExitGivenOutParams(params)); + } + + async singleExitGivenIn(params: SingleExitGivenInRangePool): Promise { + return this.exit(this._buildSingleExitGivenInParams(params)); + } + + async querySingleExitGivenIn(params: SingleExitGivenInRangePool): Promise { + return this.queryExit(this._buildSingleExitGivenInParams(params)); + } + + async multiExitGivenIn(params: MultiExitGivenInRangePool): Promise { + return this.exit(this._buildMultiExitGivenInParams(params)); + } + + async queryMultiExitGivenIn(params: MultiExitGivenInRangePool): Promise { + return this.queryExit(this._buildMultiExitGivenInParams(params)); + } + + async queryJoin(params: JoinExitRangePool): Promise { + const fn = this.instance.queryJoin; + return (await this._executeQuery(params, fn)) as JoinQueryResult; + } + + async join(params: JoinExitRangePool): Promise { + const currentBalances = params.currentBalances || (await this.getBalances()); + const to = params.recipient ? TypesConverter.toAddress(params.recipient) : params.from?.address ?? ZERO_ADDRESS; + const { tokens } = await this.getTokens(); + + const tx = await this.vault.joinPool({ + poolAddress: this.address, + poolId: this.poolId, + recipient: to, + currentBalances, + tokens, + lastChangeBlock: params.lastChangeBlock ?? 0, + protocolFeePercentage: params.protocolFeePercentage ?? 0, + data: params.data ?? '0x', + from: params.from, + }); + + const receipt = await tx.wait(); + const { deltas, protocolFeeAmounts } = expectEvent.inReceipt(receipt, 'PoolBalanceChanged').args; + return { amountsIn: deltas, dueProtocolFeeAmounts: protocolFeeAmounts, receipt }; + } + + async queryExit(params: JoinExitRangePool): Promise { + const fn = this.instance.queryExit; + return (await this._executeQuery(params, fn)) as ExitQueryResult; + } + + async exit(params: JoinExitRangePool): Promise { + const currentBalances = params.currentBalances || (await this.getBalances()); + const to = params.recipient ? TypesConverter.toAddress(params.recipient) : params.from?.address ?? ZERO_ADDRESS; + const { tokens } = await this.getTokens(); + + const tx = await this.vault.exitPool({ + poolAddress: this.address, + poolId: this.poolId, + recipient: to, + currentBalances, + tokens, + lastChangeBlock: params.lastChangeBlock ?? 0, + protocolFeePercentage: params.protocolFeePercentage ?? 0, + data: params.data ?? '0x', + from: params.from, + }); + + const receipt = await tx.wait(); + const { deltas, protocolFeeAmounts } = expectEvent.inReceipt(receipt, 'PoolBalanceChanged').args; + return { amountsOut: deltas.map((x: BigNumber) => x.mul(-1)), dueProtocolFeeAmounts: protocolFeeAmounts, receipt }; + } + + private async _executeQuery(params: JoinExitRangePool, fn: ContractFunction): Promise { + const currentBalances = params.currentBalances || (await this.getBalances()); + const to = params.recipient ? TypesConverter.toAddress(params.recipient) : params.from?.address ?? ZERO_ADDRESS; + + return fn( + this.poolId, + params.from?.address || ZERO_ADDRESS, + to, + currentBalances, + params.lastChangeBlock ?? 0, + params.protocolFeePercentage ?? 0, + params.data ?? '0x' + ); + } + + private async _buildSwapParams(kind: number, params: SwapRangePool): Promise { + const currentBalances = await this.getBalances(); + const { tokens } = await this.vault.getPoolTokens(this.poolId); + const tokenIn = typeof params.in === 'number' ? tokens[params.in] : params.in.address; + const tokenOut = typeof params.out === 'number' ? tokens[params.out] : params.out.address; + const indexIn = currentBalances[tokens.indexOf(tokenIn)]; + const indexOut = currentBalances[tokens.indexOf(tokenOut)]; + return { + kind, + poolAddress: this.address, + poolId: this.poolId, + from: params.from, + to: params.recipient ?? ZERO_ADDRESS, + tokenIn: tokenIn ?? ZERO_ADDRESS, + tokenOut: tokenOut ?? ZERO_ADDRESS, + balances: currentBalances, + indexIn: indexIn ? indexIn.toNumber() : 0, + indexOut: indexOut ? indexOut.toNumber() : 0, + lastChangeBlock: params.lastChangeBlock ?? 0, + data: params.data ?? '0x', + amount: params.amount, + }; + } + + private _buildInitParams(params: InitRangePool): JoinExitRangePool { + const { initialBalances: balances } = params; + const amountsIn = Array.isArray(balances) ? balances : Array(this.tokens.length).fill(balances); + + return { + from: params.from, + recipient: params.recipient, + protocolFeePercentage: params.protocolFeePercentage, + data: WeightedPoolEncoder.joinInit(amountsIn), + }; + } + + private _buildJoinGivenInParams(params: JoinGivenInRangePool): JoinExitRangePool { + const { amountsIn: amounts } = params; + const amountsIn = Array.isArray(amounts) ? amounts : Array(this.tokens.length).fill(amounts); + + return { + from: params.from, + recipient: params.recipient, + lastChangeBlock: params.lastChangeBlock, + currentBalances: params.currentBalances, + protocolFeePercentage: params.protocolFeePercentage, + data: WeightedPoolEncoder.joinExactTokensInForBPTOut(amountsIn, params.minimumBptOut ?? 0), + }; + } + + private _buildJoinGivenOutParams(params: JoinGivenOutRangePool): JoinExitRangePool { + return { + from: params.from, + recipient: params.recipient, + lastChangeBlock: params.lastChangeBlock, + currentBalances: params.currentBalances, + protocolFeePercentage: params.protocolFeePercentage, + data: WeightedPoolEncoder.joinTokenInForExactBPTOut(params.bptOut, this.tokens.indexOf(params.token)), + }; + } + + private _buildJoinAllGivenOutParams(params: JoinAllGivenOutRangePool): JoinExitRangePool { + return { + from: params.from, + recipient: params.recipient, + lastChangeBlock: params.lastChangeBlock, + currentBalances: params.currentBalances, + protocolFeePercentage: params.protocolFeePercentage, + data: WeightedPoolEncoder.joinAllTokensInForExactBPTOut(params.bptOut), + }; + } + + private _buildExitGivenOutParams(params: ExitGivenOutRangePool): JoinExitRangePool { + const { amountsOut: amounts } = params; + const amountsOut = Array.isArray(amounts) ? amounts : Array(this.tokens.length).fill(amounts); + return { + from: params.from, + recipient: params.recipient, + lastChangeBlock: params.lastChangeBlock, + currentBalances: params.currentBalances, + protocolFeePercentage: params.protocolFeePercentage, + data: WeightedPoolEncoder.exitBPTInForExactTokensOut(amountsOut, params.maximumBptIn ?? MAX_UINT256), + }; + } + + private _buildSingleExitGivenInParams(params: SingleExitGivenInRangePool): JoinExitRangePool { + return { + from: params.from, + recipient: params.recipient, + lastChangeBlock: params.lastChangeBlock, + currentBalances: params.currentBalances, + protocolFeePercentage: params.protocolFeePercentage, + data: WeightedPoolEncoder.exitExactBPTInForOneTokenOut(params.bptIn, this.tokens.indexOf(params.token)), + }; + } + + private _buildMultiExitGivenInParams(params: MultiExitGivenInRangePool): JoinExitRangePool { + return { + from: params.from, + recipient: params.recipient, + lastChangeBlock: params.lastChangeBlock, + currentBalances: params.currentBalances, + protocolFeePercentage: params.protocolFeePercentage, + data: WeightedPoolEncoder.exitExactBPTInForTokensOut(params.bptIn), + }; + } + + async setJoinExitEnabled(from: SignerWithAddress, joinExitEnabled: boolean): Promise { + const pool = this.instance.connect(from); + return pool.setJoinExitEnabled(joinExitEnabled); + } + + async setSwapEnabled(from: SignerWithAddress, swapEnabled: boolean): Promise { + const pool = this.instance.connect(from); + return pool.setSwapEnabled(swapEnabled); + } + + async setSwapFeePercentage(from: SignerWithAddress, swapFeePercentage: BigNumberish): Promise { + const pool = this.instance.connect(from); + return pool.setSwapFeePercentage(swapFeePercentage); + } + + async updateWeightsGradually( + from: SignerWithAddress, + startTime: BigNumberish, + endTime: BigNumberish, + endWeights: BigNumberish[] + ): Promise { + const pool = this.instance.connect(from); + + return await pool.updateWeightsGradually(startTime, endTime, endWeights); + } + + async getGradualWeightUpdateParams(from?: SignerWithAddress): Promise { + const pool = from ? this.instance.connect(from) : this.instance; + return await pool.getGradualWeightUpdateParams(); + } +} diff --git a/pkg/pool-range/test/helpers/RangePool.ts b/pkg/pool-range/test/helpers/RangePool.ts new file mode 100644 index 000000000..d8efc6e6a --- /dev/null +++ b/pkg/pool-range/test/helpers/RangePool.ts @@ -0,0 +1,115 @@ +import { Contract } from 'ethers'; +import TypesConverter from '@balancer-labs/v2-helpers/src/models/types/TypesConverter'; +import RangeTypesConverter from './TypesConverter'; +import VaultDeployer from '@balancer-labs/v2-helpers/src/models/vault/VaultDeployer'; +import BaseRangePool from './BaseRangePool'; +import { + BUFFER_PERIOD_DURATION, + NAME, + PAUSE_WINDOW_DURATION, + SYMBOL, +} from '@balancer-labs/v2-helpers/src/models/pools/base/BasePool'; +import { RawRangePoolDeployment, RangePoolDeployment } from './types'; +import Vault from '@balancer-labs/v2-helpers/src/models/vault/Vault'; +import { deploy, deployedAt } from './contract'; +import * as expectEvent from '@balancer-labs/v2-helpers/src/test/expectEvent'; +import { Account } from '@balancer-labs/v2-helpers/src/models/types/types'; +import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; +import { BigNumberish } from '@balancer-labs/v2-helpers/src/numbers'; +import { randomBytes } from 'ethers/lib/utils'; + +export default class RangePool extends BaseRangePool { + rateProviders: Account[]; + assetManagers: string[]; + + constructor( + instance: Contract, + poolId: string, + vault: Vault, + tokens: TokenList, + weights: BigNumberish[], + virtualBalances: BigNumberish[], + rateProviders: Account[], + assetManagers: string[], + swapFeePercentage: BigNumberish, + owner?: Account + ) { + super(instance, poolId, vault, tokens, weights, virtualBalances, swapFeePercentage, owner); + + this.rateProviders = rateProviders; + this.assetManagers = assetManagers; + } + + static async create(params: RawRangePoolDeployment = {}): Promise { + const vault = params?.vault ?? (await VaultDeployer.deploy(TypesConverter.toRawVaultDeployment(params))); + const deployment = RangeTypesConverter.toRangePoolDeployment(params); + const pool = await (params.fromFactory ? this._deployFromFactory : this._deployStandalone)(deployment, vault); + const poolId = await pool.getPoolId(); + + const { tokens, weights, virtualBalances, rateProviders, assetManagers, swapFeePercentage, owner } = deployment; + + return new RangePool( + pool, + poolId, + vault, + tokens, + weights, + virtualBalances, + rateProviders, + assetManagers, + swapFeePercentage, + owner + ); + } + + static async _deployStandalone(params: RangePoolDeployment, vault: Vault): Promise { + const { from } = params; + + return deploy('RangePool', { + args: [ + { + name: NAME, + symbol: SYMBOL, + tokens: params.tokens.addresses, + normalizedWeights: params.weights, + virtualBalances: params.virtualBalances, + rateProviders: TypesConverter.toAddresses(params.rateProviders), + assetManagers: params.assetManagers, + swapFeePercentage: params.swapFeePercentage, + }, + vault.address, + vault.protocolFeesProvider.address, + params.pauseWindowDuration, + params.bufferPeriodDuration, + params.owner, + ], + from, + }); + } + + static async _deployFromFactory(params: RangePoolDeployment, vault: Vault): Promise { + // Note that we only support asset managers with the standalone deploy method. + + const { tokens, weights, virtualBalances, rateProviders, swapFeePercentage, owner, from } = params; + + const factory = await deploy('RangePoolFactory', { + args: [vault.address, vault.getFeesProvider().address, PAUSE_WINDOW_DURATION, BUFFER_PERIOD_DURATION], + from, + }); + + const tx = await factory.create( + NAME, + SYMBOL, + tokens.addresses, + weights, + virtualBalances, + rateProviders, + swapFeePercentage, + owner, + randomBytes(32) + ); + const receipt = await tx.wait(); + const event = expectEvent.inReceipt(receipt, 'PoolCreated'); + return deployedAt('RangePool', event.args.pool); + } +} diff --git a/pkg/pool-range/test/helpers/TypesConverter.ts b/pkg/pool-range/test/helpers/TypesConverter.ts new file mode 100644 index 000000000..2095ba9ca --- /dev/null +++ b/pkg/pool-range/test/helpers/TypesConverter.ts @@ -0,0 +1,66 @@ +import { toNormalizedWeights } from '@balancer-labs/balancer-js'; + +import { bn, fp } from '@balancer-labs/v2-helpers/src/numbers'; +import { MONTH } from '@balancer-labs/v2-helpers/src/time'; +import { ZERO_ADDRESS } from '@balancer-labs/v2-helpers/src/constants'; +import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; +import { Account } from '@balancer-labs/v2-helpers/src/models/types/types'; +import { + RawRangePoolDeployment, + RangePoolDeployment, +} from './types'; + +const DEFAULT_PAUSE_WINDOW_DURATION = 3 * MONTH; +const DEFAULT_BUFFER_PERIOD_DURATION = MONTH; + +export function computeDecimalsFromIndex(i: number): number { + // Produces repeating series (0..18) + return i % 19; +} + +export default { + toRangePoolDeployment(params: RawRangePoolDeployment): RangePoolDeployment { + let { + tokens, + weights, + virtualBalances, + rateProviders, + assetManagers, + swapFeePercentage, + pauseWindowDuration, + bufferPeriodDuration, + } = params; + if (!params.owner) params.owner = ZERO_ADDRESS; + if (!tokens) tokens = new TokenList(); + if (!weights) weights = Array(tokens.length).fill(fp(1)); + weights = toNormalizedWeights(weights.map(bn)); + if (!virtualBalances) virtualBalances = Array(tokens.length).fill(0); + if (!swapFeePercentage) swapFeePercentage = bn(1e16); + if (!pauseWindowDuration) pauseWindowDuration = DEFAULT_PAUSE_WINDOW_DURATION; + if (!bufferPeriodDuration) bufferPeriodDuration = DEFAULT_BUFFER_PERIOD_DURATION; + if (!rateProviders) rateProviders = Array(tokens.length).fill(ZERO_ADDRESS); + if (!assetManagers) assetManagers = Array(tokens.length).fill(ZERO_ADDRESS); + + return { + tokens, + weights, + virtualBalances, + rateProviders, + assetManagers, + swapFeePercentage, + pauseWindowDuration, + bufferPeriodDuration, + owner: this.toAddress(params.owner), + from: params.from, + }; + }, + + toAddresses(to: Account[]): string[] { + return to.map(this.toAddress); + }, + + toAddress(to?: Account): string { + if (!to) return ZERO_ADDRESS; + return typeof to === 'string' ? to : to.address; + }, +}; diff --git a/pkg/pool-range/test/helpers/contract.ts b/pkg/pool-range/test/helpers/contract.ts new file mode 100644 index 000000000..00481328a --- /dev/null +++ b/pkg/pool-range/test/helpers/contract.ts @@ -0,0 +1,51 @@ +import { ethers } from 'hardhat'; +import { Contract } from 'ethers'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address'; + +import { Artifacts } from 'hardhat/internal/artifacts'; +import { Artifact } from 'hardhat/types'; +import path from 'path'; +import { Dictionary } from 'lodash'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export type ContractDeploymentParams = { + from?: SignerWithAddress; + args?: Array; + libraries?: Dictionary; +}; + +// Deploys a contract, with optional `from` address and arguments. +// Local contracts are deployed by simply passing the contract name, contracts from other packages must be prefixed by +// the package name, without the @balancer-labs scope. Note that the full path is never required. +// +// For example, to deploy Vault.sol from the package that holds its artifacts, use `deploy('Vault')`. To deploy it from +// a different package, use `deploy('v2-vault/Vault')`, assuming the Vault's package is @balancer-labs/v2-vault. +export async function deploy( + contract: string, + { from, args, libraries }: ContractDeploymentParams = {} +): Promise { + if (!args) args = []; + if (!from) from = (await ethers.getSigners())[0]; + + const artifact = getArtifact(contract); + + const factory = await ethers.getContractFactoryFromArtifact(artifact, { signer: from, libraries }); + const instance = await factory.deploy(...args); + + return instance.deployed(); +} + +// Creates a contract object for a contract deployed at a known address. The `contract` argument follows the same rules +// as in `deploy`. +export async function deployedAt(contract: string, address: string): Promise { + const artifact = getArtifact(contract); + return ethers.getContractAt(artifact.abi, address); +} + +export function getArtifact(contract: string): Artifact { + let artifactsPath: string = path.resolve('./artifacts'); + + const artifacts = new Artifacts(artifactsPath); + return artifacts.readArtifactSync(contract.split('/').slice(-1)[0]); +} diff --git a/pkg/pool-range/test/helpers/types.ts b/pkg/pool-range/test/helpers/types.ts new file mode 100644 index 000000000..c6c4633da --- /dev/null +++ b/pkg/pool-range/test/helpers/types.ts @@ -0,0 +1,171 @@ +import { BigNumber, ContractReceipt } from 'ethers'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address'; + +import { BigNumberish } from '@balancer-labs/v2-helpers/src//numbers'; + +import Token from '@balancer-labs/v2-helpers/src/models/tokens/Token'; +import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; +import { Account, NAry } from '@balancer-labs/v2-helpers/src/models/types/types'; +import Vault from '@balancer-labs/v2-helpers/src/models/vault/Vault'; + +export type RawRangePoolDeployment = { + tokens?: TokenList; + weights?: BigNumberish[]; + virtualBalances?: BigNumberish[]; + rateProviders?: Account[]; + assetManagers?: string[]; + swapFeePercentage?: BigNumberish; + pauseWindowDuration?: BigNumberish; + bufferPeriodDuration?: BigNumberish; + owner?: Account; + admin?: SignerWithAddress; + from?: SignerWithAddress; + vault?: Vault; + fromFactory?: boolean; +}; + +export type RangePoolDeployment = { + tokens: TokenList; + weights: BigNumberish[]; + virtualBalances?: BigNumberish[]; + rateProviders: Account[]; + assetManagers: string[]; + swapFeePercentage: BigNumberish; + pauseWindowDuration: BigNumberish; + bufferPeriodDuration: BigNumberish; + owner: Account; + admin?: SignerWithAddress; + from?: SignerWithAddress; +}; + +export type SwapRangePool = { + in: number | Token; + out: number | Token; + amount: BigNumberish; + balances?: BigNumberish[]; + recipient?: Account; + from?: SignerWithAddress; + lastChangeBlock?: BigNumberish; + data?: string; +}; + +export type JoinExitRangePool = { + recipient?: Account; + currentBalances?: BigNumberish[]; + lastChangeBlock?: BigNumberish; + protocolFeePercentage?: BigNumberish; + data?: string; + from?: SignerWithAddress; +}; + +export type InitRangePool = { + initialBalances: NAry; + from?: SignerWithAddress; + recipient?: Account; + protocolFeePercentage?: BigNumberish; +}; + +export type JoinGivenInRangePool = { + amountsIn: NAry; + minimumBptOut?: BigNumberish; + from?: SignerWithAddress; + recipient?: Account; + lastChangeBlock?: BigNumberish; + currentBalances?: BigNumberish[]; + protocolFeePercentage?: BigNumberish; +}; + +export type JoinGivenOutRangePool = { + token: number | Token; + bptOut: BigNumberish; + from?: SignerWithAddress; + recipient?: Account; + lastChangeBlock?: BigNumberish; + currentBalances?: BigNumberish[]; + protocolFeePercentage?: BigNumberish; +}; + +export type JoinAllGivenOutRangePool = { + bptOut: BigNumberish; + from?: SignerWithAddress; + recipient?: Account; + lastChangeBlock?: BigNumberish; + currentBalances?: BigNumberish[]; + protocolFeePercentage?: BigNumberish; +}; + +export type ExitGivenOutRangePool = { + amountsOut: NAry; + maximumBptIn?: BigNumberish; + recipient?: Account; + from?: SignerWithAddress; + lastChangeBlock?: BigNumberish; + currentBalances?: BigNumberish[]; + protocolFeePercentage?: BigNumberish; +}; + +export type SingleExitGivenInRangePool = { + bptIn: BigNumberish; + token: number | Token; + recipient?: Account; + from?: SignerWithAddress; + lastChangeBlock?: BigNumberish; + currentBalances?: BigNumberish[]; + protocolFeePercentage?: BigNumberish; +}; + +export type MultiExitGivenInRangePool = { + bptIn: BigNumberish; + recipient?: Account; + from?: SignerWithAddress; + lastChangeBlock?: BigNumberish; + currentBalances?: BigNumberish[]; + protocolFeePercentage?: BigNumberish; +}; + +export type JoinResult = { + amountsIn: BigNumber[]; + dueProtocolFeeAmounts: BigNumber[]; + receipt: ContractReceipt; +}; + +export type ExitResult = { + amountsOut: BigNumber[]; + dueProtocolFeeAmounts: BigNumber[]; + receipt: ContractReceipt; +}; + +export type SwapResult = { + amount: BigNumber; + receipt: ContractReceipt; +}; + +export type JoinQueryResult = { + bptOut: BigNumber; + amountsIn: BigNumber[]; +}; + +export type ExitQueryResult = { + bptIn: BigNumber; + amountsOut: BigNumber[]; +}; + +export type VoidResult = { + receipt: ContractReceipt; +}; + +export type PoolQueryResult = JoinQueryResult | ExitQueryResult; + +export type GradualWeightUpdateParams = { + startTime: BigNumber; + endTime: BigNumber; + startWeights: BigNumber[]; + endWeights: BigNumber[]; +}; + +export type GradualSwapFeeUpdateParams = { + startTime: BigNumber; + endTime: BigNumber; + startSwapFeePercentage: BigNumber; + endSwapFeePercentage: BigNumber; +}; From 5ae9ed79298054b4e338496a0e12245f81441255 Mon Sep 17 00:00:00 2001 From: dcpp Date: Wed, 3 Sep 2025 09:44:21 +0300 Subject: [PATCH 05/13] Tests completion --- pkg/pool-range/README.md | 55 +++---------------- pkg/pool-range/contracts/BaseRangePool.sol | 11 +--- pkg/pool-range/contracts/RangePoolFactory.sol | 2 +- pkg/pool-range/test/BaseRangePool.behavior.ts | 41 +++----------- pkg/pool-range/test/BaseRangePool.test.ts | 4 +- pkg/pool-range/test/helpers/BaseRangePool.ts | 19 +++---- pkg/pool-range/test/helpers/types.ts | 2 +- 7 files changed, 28 insertions(+), 106 deletions(-) diff --git a/pkg/pool-range/README.md b/pkg/pool-range/README.md index 6199501d7..df0282589 100644 --- a/pkg/pool-range/README.md +++ b/pkg/pool-range/README.md @@ -1,69 +1,28 @@ -# Balancer +# Range -# Balancer V2 Weighted Pools +# Range Weighted Pools -[![NPM Package](https://img.shields.io/npm/v/@balancer-labs/v2-pool-weighted.svg)](https://www.npmjs.org/package/@balancer-labs/v2-pool-weighted) -This package contains the source code for Balancer V2 Weighted Pools, that is, Pools that swap tokens by enforcing a Constant Weighted Product invariant. +This package contains the source code for Range Weighted Pools, that is, Pools that swap tokens by enforcing a Constant Weighted Product invariant. -The pool currently in existence is [`WeightedPool`](./contracts/WeightedPool.sol) (basic twenty token version). +The pool currently in existence is [`RangePool`](./contracts/RangePool.sol) (basic ten token version). -There are subdirectories for common variants, which automatically updates some of their attributes to support more complex use cases. Examples are [`LiquidityBootstrappingPool`](./contracts/lbp/LiquidityBootstrappingPool.sol) for auction-like mechanisms, and [`ManagedPool`](./contracts/managed/ManagedPool.sol) for managed portfolios. - -The `lib` directory contains internal and external common libraries, such as [`CircuitBreakerLib`](./contracts/lib/CircuitBreakerLib.sol). - -| :warning: | Managed Pools are still undergoing development and may contain bugs and/or change significantly. | -| --------- | :-------------------------------------------------------------------------------------------------- | - -Another useful contract is [`WeightedMath`](./contracts/WeightedMath.sol), which implements the low level calculations required for swaps, joins, exits and price calculations. +Another useful contract is [`RangeMath`](./contracts/RangeMath.sol), which implements the low level calculations required for swaps, joins, exits and price calculations. ## Overview ### Installation ```console -$ npm install @balancer-labs/v2-pool-weighted +$ git clone https://github.com/puzzlenetwork/ranges-sol ``` ### Usage This package can be used in multiple ways, including interacting with already deployed Pools, performing local testing, or even creating new Pool types that also use the Constant Weighted Product invariant. -To get the address of deployed contracts in both mainnet and various test networks, see [`balancer-deployments` repository](https://github.com/balancer/balancer-deployments). - -Sample Weighted Pool that computes weights dynamically on every swap, join and exit: - -```solidity -pragma solidity ^0.7.0; - -import '@balancer-labs/v2-pool-weighted/contracts/BaseWeightedPool.sol'; - -contract DynamicWeightedPool is BaseWeightedPool { - uint256 private immutable _creationTime; +To get the address of deployed contracts in both mainnet and various test networks, see [`range-deployments` repository](https://github.com/puzzlenetwork/range-deployments.git). - constructor() { - _creationTime = block.timestamp; - } - - function _getNormalizedWeightsAndMaxWeightIndex() internal view override returns (uint256[] memory) { - uint256[] memory weights = new uint256[](2); - - // Change weights from 50-50 to 30-70 one month after deployment - if (block.timestamp < (_creationTime + 1 month)) { - weights[0] = 0.5e18; - weights[1] = 0.5e18; - } else { - weights[0] = 0.3e18; - weights[1] = 0.7e18; - } - - return (weights, 1); - } - - ... -} - -``` ## Licensing diff --git a/pkg/pool-range/contracts/BaseRangePool.sol b/pkg/pool-range/contracts/BaseRangePool.sol index 4bda77373..0ad4badae 100644 --- a/pkg/pool-range/contracts/BaseRangePool.sol +++ b/pkg/pool-range/contracts/BaseRangePool.sol @@ -49,14 +49,7 @@ abstract contract BaseRangePool is BaseGeneralPool { ) BasePool( vault, - // Given BaseMinimalSwapInfoPool supports both of these specializations, and this Pool never registers - // or deregisters any tokens after construction, picking Two Token when the Pool only has two tokens is free - // gas savings. - // If the pool is expected to be able register new tokens in future, we must choose MINIMAL_SWAP_INFO - // as clearly the TWO_TOKEN specification doesn't support adding extra tokens in future. - tokens.length == 2 && !mutableTokens - ? IVault.PoolSpecialization.TWO_TOKEN - : IVault.PoolSpecialization.MINIMAL_SWAP_INFO, + IVault.PoolSpecialization.GENERAL, name, symbol, tokens, @@ -260,6 +253,8 @@ abstract contract BaseRangePool is BaseGeneralPool { // Initialization is still a join, so we need to do post-join work. Since we are not paying protocol fees, // and all we need to do is update the invariant, call `_updatePostJoinExit` here instead of `_afterJoinExit`. + _increaseVirtualBalances(amountsIn); + _updatePostJoinExit(invariantAfterJoin); return (bptAmountOut, amountsIn); diff --git a/pkg/pool-range/contracts/RangePoolFactory.sol b/pkg/pool-range/contracts/RangePoolFactory.sol index ac4316810..ae18d7324 100644 --- a/pkg/pool-range/contracts/RangePoolFactory.sol +++ b/pkg/pool-range/contracts/RangePoolFactory.sol @@ -40,7 +40,7 @@ contract RangePoolFactory is BasePoolFactory { } /** - * @dev Deploys a new `WeightedPool`. + * @dev Deploys a new `RangePool`. */ function create( string memory name, diff --git a/pkg/pool-range/test/BaseRangePool.behavior.ts b/pkg/pool-range/test/BaseRangePool.behavior.ts index 3e426fe58..bd4fb5361 100644 --- a/pkg/pool-range/test/BaseRangePool.behavior.ts +++ b/pkg/pool-range/test/BaseRangePool.behavior.ts @@ -70,9 +70,7 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { }); it('uses the corresponding specialization', async () => { - const expectedSpecialization = - numberOfTokens == 2 ? PoolSpecialization.TwoTokenPool : PoolSpecialization.MinimalSwapInfoPool; - + const expectedSpecialization = PoolSpecialization.GeneralPool; const { address, specialization } = await pool.getRegisteredInfo(); expect(address).to.equal(pool.address); expect(specialization).to.equal(expectedSpecialization); @@ -201,7 +199,7 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { context('once initialized', () => { let expectedBptOut: BigNumberish; - const amountsIn = ZEROS.map((n, i) => (i === 1 ? fp(0.1) : n)); + const amountsIn = ZEROS.map((n, i) => (i === 1 ? fp(0.1) : fp(0.2))); sharedBeforeEach('initialize pool', async () => { await pool.init({ recipient, initialBalances, from: lp }); @@ -223,6 +221,10 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { // Make sure received BPT is close to what we expect const currentBptBalance = await pool.balanceOf(recipient); expect(currentBptBalance.sub(previousBptBalance)).to.be.equalWithError(expectedBptOut, 0.0001); + + const virtualBalances = await pool.getVirtualBalances(); + expect(virtualBalances[0]).to.be.gt(0); + expect(virtualBalances[1]).to.be.gt(0); }); it('can tell how much BPT it will give in return', async () => { @@ -679,16 +681,10 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { let joinResult = await pool.joinGivenIn({ from: lp, amountsIn: fp(100), protocolFeePercentage }); expect(joinResult.dueProtocolFeeAmounts).to.be.zeros; - joinResult = await pool.joinGivenOut({ from: lp, bptOut: fp(1), token: 0, protocolFeePercentage }); - expect(joinResult.dueProtocolFeeAmounts).to.be.zeros; - joinResult = await pool.joinAllGivenOut({ from: lp, bptOut: fp(0.1) }); expect(joinResult.dueProtocolFeeAmounts).to.be.zeros; - let exitResult = await pool.singleExitGivenIn({ from: lp, bptIn: fp(10), token: 0, protocolFeePercentage }); - expect(exitResult.dueProtocolFeeAmounts).to.be.zeros; - - exitResult = await pool.multiExitGivenIn({ from: lp, bptIn: fp(10), protocolFeePercentage }); + let exitResult = await pool.multiExitGivenIn({ from: lp, bptIn: fp(10), protocolFeePercentage }); expect(exitResult.dueProtocolFeeAmounts).to.be.zeros; joinResult = await pool.joinGivenIn({ from: lp, amountsIn: fp(10), protocolFeePercentage }); @@ -712,18 +708,6 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { expect(result.dueProtocolFeeAmounts).to.be.zeros; }); - it('no protocol fees on exit exact BPT in for one token out', async () => { - const result = await pool.singleExitGivenIn({ - from: lp, - bptIn: fp(0.5), - token: 0, - currentBalances, - protocolFeePercentage, - }); - - expect(result.dueProtocolFeeAmounts).to.be.zeros; - }); - it('no protocol fees on exit exact BPT in for all tokens out', async () => { const result = await pool.multiExitGivenIn({ from: lp, @@ -734,17 +718,6 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { expect(result.dueProtocolFeeAmounts).to.be.zeros; }); - - it('no protocol fees on exit BPT In for exact tokens out', async () => { - const result = await pool.exitGivenOut({ - from: lp, - amountsOut: fp(1), - currentBalances, - protocolFeePercentage, - }); - - expect(result.dueProtocolFeeAmounts).to.be.zeros; - }); }); }); } diff --git a/pkg/pool-range/test/BaseRangePool.test.ts b/pkg/pool-range/test/BaseRangePool.test.ts index 4f91dd3af..937e674e1 100644 --- a/pkg/pool-range/test/BaseRangePool.test.ts +++ b/pkg/pool-range/test/BaseRangePool.test.ts @@ -20,7 +20,7 @@ describe('BaseRangePool', function () { itBehavesAsRangePool(2); }); - /*context('for a 3 token pool', () => { + context('for a 3 token pool', () => { itBehavesAsRangePool(3); }); @@ -32,5 +32,5 @@ describe('BaseRangePool', function () { await expect(RangePool.create({ tokens, weights })).to.be.revertedWith('MAX_TOKENS'); }); - });*/ + }); }); diff --git a/pkg/pool-range/test/helpers/BaseRangePool.ts b/pkg/pool-range/test/helpers/BaseRangePool.ts index 43b3d383d..275d16f09 100644 --- a/pkg/pool-range/test/helpers/BaseRangePool.ts +++ b/pkg/pool-range/test/helpers/BaseRangePool.ts @@ -45,7 +45,6 @@ const MIN_INVARIANT_RATIO = fp(0.7); export default class BaseRangePool extends BasePool { weights: BigNumberish[]; - vbalances: BigNumberish[]; constructor( instance: Contract, @@ -53,24 +52,18 @@ export default class BaseRangePool extends BasePool { vault: Vault, tokens: TokenList, weights: BigNumberish[], - virtualBalances: BigNumberish[], swapFeePercentage: BigNumberish, owner?: Account ) { super(instance, poolId, vault, tokens, swapFeePercentage, owner); this.weights = weights; - this.vbalances = virtualBalances; } get normalizedWeights(): BigNumberish[] { return this.weights; } - get virtualBalances(): BigNumberish[] { - return this.vbalances; - } - async getLastPostJoinExitInvariant(): Promise { return this.instance.getLastPostJoinExitInvariant(); } @@ -99,6 +92,10 @@ export default class BaseRangePool extends BasePool { return this.instance.getNormalizedWeights(); } + async getVirtualBalances(): Promise { + return this.instance.getVirtualBalances(); + } + async estimateInvariant(currentBalances?: BigNumberish[]): Promise { if (!currentBalances) currentBalances = await this.getBalances(); const scalingFactors = await this.getScalingFactors(); @@ -110,7 +107,7 @@ export default class BaseRangePool extends BasePool { } async estimateGivenIn(params: SwapRangePool, currentBalances?: BigNumberish[]): Promise { - if (!currentBalances) currentBalances = await this.getBalances(); + if (!currentBalances) currentBalances = await this.getVirtualBalances(); const [tokenIn, tokenOut] = this.tokens.indicesOfTwoTokens(params.in, params.out); return bn( @@ -377,8 +374,6 @@ export default class BaseRangePool extends BasePool { const { tokens } = await this.vault.getPoolTokens(this.poolId); const tokenIn = typeof params.in === 'number' ? tokens[params.in] : params.in.address; const tokenOut = typeof params.out === 'number' ? tokens[params.out] : params.out.address; - const indexIn = currentBalances[tokens.indexOf(tokenIn)]; - const indexOut = currentBalances[tokens.indexOf(tokenOut)]; return { kind, poolAddress: this.address, @@ -388,8 +383,8 @@ export default class BaseRangePool extends BasePool { tokenIn: tokenIn ?? ZERO_ADDRESS, tokenOut: tokenOut ?? ZERO_ADDRESS, balances: currentBalances, - indexIn: indexIn ? indexIn.toNumber() : 0, - indexOut: indexOut ? indexOut.toNumber() : 0, + indexIn: typeof params.in === 'number' ? params.in : 0, + indexOut: typeof params.out === 'number' ? params.out : 0, lastChangeBlock: params.lastChangeBlock ?? 0, data: params.data ?? '0x', amount: params.amount, diff --git a/pkg/pool-range/test/helpers/types.ts b/pkg/pool-range/test/helpers/types.ts index c6c4633da..eeb49c75d 100644 --- a/pkg/pool-range/test/helpers/types.ts +++ b/pkg/pool-range/test/helpers/types.ts @@ -27,7 +27,7 @@ export type RawRangePoolDeployment = { export type RangePoolDeployment = { tokens: TokenList; weights: BigNumberish[]; - virtualBalances?: BigNumberish[]; + virtualBalances: BigNumberish[]; rateProviders: Account[]; assetManagers: string[]; swapFeePercentage: BigNumberish; From 78a7e7df005a5b4296a536fc60ebf0b05aeda08b Mon Sep 17 00:00:00 2001 From: dcpp Date: Wed, 3 Sep 2025 19:13:16 +0300 Subject: [PATCH 06/13] Add initial virtual balances to tests --- pkg/pool-range/README.md | 3 +- pkg/pool-range/contracts/BaseRangePool.sol | 3 +- .../contracts/ExternalRangeMath.sol | 10 ++++ pkg/pool-range/contracts/RangeMath.sol | 58 +++++++++++++++++-- pkg/pool-range/test/BaseRangePool.behavior.ts | 19 +++--- pkg/pool-range/test/helpers/BaseRangePool.ts | 22 +++---- 6 files changed, 90 insertions(+), 25 deletions(-) diff --git a/pkg/pool-range/README.md b/pkg/pool-range/README.md index df0282589..bfd64ac50 100644 --- a/pkg/pool-range/README.md +++ b/pkg/pool-range/README.md @@ -14,7 +14,8 @@ Another useful contract is [`RangeMath`](./contracts/RangeMath.sol), which imple ### Installation ```console -$ git clone https://github.com/puzzlenetwork/ranges-sol +$ git clone --recurse-submodules https://github.com/puzzlenetwork/ranges-sol + ``` ### Usage diff --git a/pkg/pool-range/contracts/BaseRangePool.sol b/pkg/pool-range/contracts/BaseRangePool.sol index 0ad4badae..0c6e70649 100644 --- a/pkg/pool-range/contracts/BaseRangePool.sol +++ b/pkg/pool-range/contracts/BaseRangePool.sol @@ -22,6 +22,7 @@ import "@balancer-labs/v2-solidity-utils/contracts/helpers/InputHelpers.sol"; import "@balancer-labs/v2-pool-utils/contracts/BaseGeneralPool.sol"; import "@balancer-labs/v2-pool-utils/contracts/lib/BasePoolMath.sol"; +import "@balancer-labs/v2-pool-weighted/contracts/WeightedMath.sol"; import "./RangeMath.sol"; @@ -179,7 +180,7 @@ abstract contract BaseRangePool is BaseGeneralPool { _require(tokenOutIdx < balances.length, Errors.OUT_OF_BOUNDS); _require(balances[tokenOutIdx] >= swapRequest.amount, Errors.INSUFFICIENT_BALANCE); uint256 amountIn = - WeightedMath._calcInGivenOut( + RangeMath._calcInGivenOut( _getVirtualBalance(swapRequest.tokenIn), _getNormalizedWeight(swapRequest.tokenIn), _getVirtualBalance(swapRequest.tokenOut), diff --git a/pkg/pool-range/contracts/ExternalRangeMath.sol b/pkg/pool-range/contracts/ExternalRangeMath.sol index 9e4a85cb7..281311819 100644 --- a/pkg/pool-range/contracts/ExternalRangeMath.sol +++ b/pkg/pool-range/contracts/ExternalRangeMath.sol @@ -32,6 +32,16 @@ contract ExternalRangeMath { return RangeMath._calcOutGivenIn(balanceIn, weightIn, balanceOut, weightOut, amountIn, factBalance); } + function calcInGivenOut( + uint256 balanceIn, + uint256 weightIn, + uint256 balanceOut, + uint256 weightOut, + uint256 amountOut + ) external pure returns (uint256) { + return RangeMath._calcInGivenOut(balanceIn, weightIn, balanceOut, weightOut, amountOut); + } + function calcBptOutGivenExactTokensIn( uint256[] memory balances, uint256[] memory amountsIn, diff --git a/pkg/pool-range/contracts/RangeMath.sol b/pkg/pool-range/contracts/RangeMath.sol index d9f9bd2c6..a6fd09993 100644 --- a/pkg/pool-range/contracts/RangeMath.sol +++ b/pkg/pool-range/contracts/RangeMath.sol @@ -14,10 +14,8 @@ pragma solidity ^0.7.0; -import "@balancer-labs/v2-solidity-utils/contracts/helpers/InputHelpers.sol"; import "@balancer-labs/v2-solidity-utils/contracts/math/FixedPoint.sol"; import "@balancer-labs/v2-solidity-utils/contracts/math/Math.sol"; -import "@balancer-labs/v2-pool-weighted/contracts/WeightedMath.sol"; // These functions start with an underscore, as if they were part of a contract and not a library. At some point this // should be fixed. @@ -27,7 +25,7 @@ library RangeMath { using FixedPoint for uint256; // Computes how many tokens can be taken out of a pool if `amountIn` are sent, given the - // current balances and weights. + // virtual balances and weights. function _calcOutGivenIn( uint256 balanceIn, uint256 weightIn, @@ -38,11 +36,61 @@ library RangeMath { ) internal pure returns (uint256) { /********************************************************************************************** // outGivenIn // - // aO = _calcOutGivenIn(..) // + // aO = amountOut // + // bO = balanceOut // + // bI = balanceIn / / bI \ (wI / wO) \ // + // aI = amountIn aO = bO * | 1 - | -------------------------- | ^ | // + // wI = weightIn \ \ ( bI + aI ) / / // + // wO = weightOut // // if a0 exceeds factBalance, then a0 = factBalance // // **********************************************************************************************/ - return Math.min(factBalance, WeightedMath._calcOutGivenIn(balanceIn, weightIn, balanceOut, weightOut, amountIn)); + // Amount out, so we round down overall. + + // The multiplication rounds down, and the subtrahend (power) rounds up (so the base rounds up too). + // Because bI / (bI + aI) <= 1, the exponent rounds down. + + uint256 denominator = balanceIn.add(amountIn); + uint256 base = balanceIn.divUp(denominator); + uint256 exponent = weightIn.divDown(weightOut); + uint256 power = base.powUp(exponent); + + return Math.min(factBalance, balanceOut.mulDown(power.complement())); + } + + // Computes how many tokens must be sent to a pool in order to take `amountOut`, given the + // current balances and weights. + function _calcInGivenOut( + uint256 balanceIn, + uint256 weightIn, + uint256 balanceOut, + uint256 weightOut, + uint256 amountOut + ) internal pure returns (uint256) { + /********************************************************************************************** + // inGivenOut // + // aO = amountOut // + // bO = balanceOut // + // bI = balanceIn / / bO \ (wO / wI) \ // + // aI = amountIn aI = bI * | | -------------------------- | ^ - 1 | // + // wI = weightIn \ \ ( bO - aO ) / / // + // wO = weightOut // + **********************************************************************************************/ + + // Amount in, so we round up overall. + + // The multiplication rounds up, and the power rounds up (so the base rounds up too). + // Because b0 / (b0 - a0) >= 1, the exponent rounds up. + + uint256 base = balanceOut.divUp(balanceOut.sub(amountOut)); + uint256 exponent = weightOut.divUp(weightIn); + uint256 power = base.powUp(exponent); + + // Because the base is larger than one (and the power rounds up), the power should always be larger than one, so + // the following subtraction should never revert. + uint256 ratio = power.sub(FixedPoint.ONE); + + return balanceIn.mulUp(ratio); } function _calcBptOutGivenExactTokensIn( diff --git a/pkg/pool-range/test/BaseRangePool.behavior.ts b/pkg/pool-range/test/BaseRangePool.behavior.ts index bd4fb5361..b15ff11b0 100644 --- a/pkg/pool-range/test/BaseRangePool.behavior.ts +++ b/pkg/pool-range/test/BaseRangePool.behavior.ts @@ -17,8 +17,8 @@ import RangePool from './helpers/RangePool'; export function itBehavesAsRangePool(numberOfTokens: number): void { const POOL_SWAP_FEE_PERCENTAGE = fp(0.01); const WEIGHTS = [fp(30), fp(70), fp(5), fp(5)]; - const VIRTUAL_BALANCES = [fp(0), fp(0), fp(0), fp(0)]; - const INITIAL_BALANCES = [fp(0.9), fp(1.8), fp(2.7), fp(3.6)]; + const VIRTUAL_BALANCES = [fp(1), fp(2), fp(3), fp(4)]; + const INITIAL_BALANCES = [fp(0.1), fp(0.2), fp(0.3), fp(0.4)]; const GENERAL_POOL_ONSWAP = 'onSwap((uint8,address,address,uint256,bytes32,uint256,address,address,bytes),uint256[],uint256,uint256)'; @@ -511,13 +511,14 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { expect(result.amount).to.be.equalWithError(expectedAmountOut, 0.05); }); - it('reverts if token in exceeds max in ratio', async () => { + // excluded since max in ratio is not checked + /*it('reverts if token in exceeds max in ratio', async () => { const maxAmountIn = await pool.getMaxIn(1); const maxAmountInWithFees = fpMul(maxAmountIn, POOL_SWAP_FEE_PERCENTAGE.add(fp(1))); const amount = maxAmountInWithFees.add(fp(1)); await expect(pool.swapGivenIn({ in: 1, out: 0, amount, from: lp })).to.be.revertedWith('MAX_IN_RATIO'); - }); + });*/ it('reverts if token in is not in the pool', async () => { await expect(pool.swapGivenIn({ in: allTokens.GRT, out: 0, amount: 1, from: lp })).to.be.revertedWith( @@ -561,7 +562,7 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { }); it('calculates amount in', async () => { - const amount = fp(0.1); + const amount = fp(0.01); const expectedAmountIn = await pool.estimateGivenOut({ in: 1, out: 0, amount }); const result = await pool.swapGivenOut({ in: 1, out: 0, amount, from: lp, recipient }); @@ -578,11 +579,12 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { expect(result.amount).to.be.equalWithError(expectedAmountIn, 0.1); }); - it('reverts if token in exceeds max out ratio', async () => { + // excluded since max out ratio is not checked + /*it('reverts if token in exceeds max out ratio', async () => { const amount = (await pool.getMaxOut(0)).add(2); await expect(pool.swapGivenOut({ in: 1, out: 0, amount, from: lp })).to.be.revertedWith('MAX_OUT_RATIO'); - }); + });*/ it('reverts if token in is not in the pool when given out', async () => { await expect(pool.swapGivenOut({ in: allTokens.GRT, out: 0, amount: 1, from: lp })).to.be.revertedWith( @@ -709,9 +711,10 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { }); it('no protocol fees on exit exact BPT in for all tokens out', async () => { + const bptBalance = await pool.balanceOf(recipient); const result = await pool.multiExitGivenIn({ from: lp, - bptIn: fp(1), + bptIn: bptBalance, currentBalances, protocolFeePercentage, }); diff --git a/pkg/pool-range/test/helpers/BaseRangePool.ts b/pkg/pool-range/test/helpers/BaseRangePool.ts index 275d16f09..401bc25b3 100644 --- a/pkg/pool-range/test/helpers/BaseRangePool.ts +++ b/pkg/pool-range/test/helpers/BaseRangePool.ts @@ -106,30 +106,32 @@ export default class BaseRangePool extends BasePool { ); } - async estimateGivenIn(params: SwapRangePool, currentBalances?: BigNumberish[]): Promise { - if (!currentBalances) currentBalances = await this.getVirtualBalances(); + async estimateGivenIn(params: SwapRangePool, virtualBalances?: BigNumberish[]): Promise { + const balances = await this.getBalances(); + if (!virtualBalances) virtualBalances = await this.getVirtualBalances(); const [tokenIn, tokenOut] = this.tokens.indicesOfTwoTokens(params.in, params.out); - return bn( + const amountOut = bn( calcOutGivenIn( - currentBalances[tokenIn], + virtualBalances[tokenIn], this.weights[tokenIn], - currentBalances[tokenOut], + virtualBalances[tokenOut], this.weights[tokenOut], params.amount ) ); + return amountOut.lt(balances[tokenOut]) ? amountOut : balances[tokenOut]; } - async estimateGivenOut(params: SwapRangePool, currentBalances?: BigNumberish[]): Promise { - if (!currentBalances) currentBalances = await this.getBalances(); + async estimateGivenOut(params: SwapRangePool, virtualBalances?: BigNumberish[]): Promise { + if (!virtualBalances) virtualBalances = await this.getVirtualBalances(); const [tokenIn, tokenOut] = this.tokens.indicesOfTwoTokens(params.in, params.out); return bn( calcInGivenOut( - currentBalances[tokenIn], + virtualBalances[tokenIn], this.weights[tokenIn], - currentBalances[tokenOut], + virtualBalances[tokenOut], this.weights[tokenOut], params.amount ) @@ -149,7 +151,7 @@ export default class BaseRangePool extends BasePool { let i = 1; while (i < balances.length && ratioMin.gt(0)) { const tmp = amountsIn[i].div(balances[i]); - if (tmp < ratioMin) ratioMin = tmp; + ratioMin = tmp.lt(ratioMin) ? tmp : ratioMin; i++; } return fp(bptTotalSupply.mul(ratioMin)); From 214630c054d8367ae45bb772d30e7f7fcb2aea39 Mon Sep 17 00:00:00 2001 From: dcpp Date: Thu, 4 Sep 2025 11:45:49 +0300 Subject: [PATCH 07/13] Test decimals and virtual balances --- pkg/pool-range/test/BaseRangePool.behavior.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/pkg/pool-range/test/BaseRangePool.behavior.ts b/pkg/pool-range/test/BaseRangePool.behavior.ts index b15ff11b0..e86cb1220 100644 --- a/pkg/pool-range/test/BaseRangePool.behavior.ts +++ b/pkg/pool-range/test/BaseRangePool.behavior.ts @@ -17,7 +17,7 @@ import RangePool from './helpers/RangePool'; export function itBehavesAsRangePool(numberOfTokens: number): void { const POOL_SWAP_FEE_PERCENTAGE = fp(0.01); const WEIGHTS = [fp(30), fp(70), fp(5), fp(5)]; - const VIRTUAL_BALANCES = [fp(1), fp(2), fp(3), fp(4)]; + const VIRTUAL_BALANCES = [fp(10), fp(20), fp(30), fp(40)]; const INITIAL_BALANCES = [fp(0.1), fp(0.2), fp(0.3), fp(0.4)]; const GENERAL_POOL_ONSWAP = 'onSwap((uint8,address,address,uint256,bytes32,uint256,address,address,bytes),uint256[],uint256,uint256)'; @@ -33,13 +33,13 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { async function deployPool(params: RawRangePoolDeployment = {}): Promise { pool = await RangePool.create({ - vault, - tokens, - weights, - virtualBalances, - swapFeePercentage: POOL_SWAP_FEE_PERCENTAGE, - ...params, - }); + vault, + tokens, + weights, + virtualBalances, + swapFeePercentage: POOL_SWAP_FEE_PERCENTAGE, + ...params, + }); } before('setup signers', async () => { @@ -50,7 +50,16 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { vault = await Vault.create(); const tokenAmounts = fp(100); - allTokens = await TokenList.create(['MKR', 'DAI', 'SNX', 'BAT', 'GRT'], { sorted: true }); + allTokens = await TokenList.create( + [ + { symbol: 'MKR', decimals: 18 }, + { symbol: 'DAI', decimals: 6 }, + { symbol: 'SNX', decimals: 8 }, + { symbol: 'BAT', decimals: 18 }, + { symbol: 'GRT', decimals: 6 }, + ], + { sorted: true } + ); await allTokens.mint({ to: lp, amount: tokenAmounts }); await allTokens.approve({ to: vault.address, from: lp, amount: tokenAmounts }); }); @@ -109,6 +118,11 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { it('sets the decimals', async () => { expect(await pool.decimals()).to.equal(18); }); + + it('sets the virtual balances', async () => { + expect((await pool.getVirtualBalances())[0]).to.equal(VIRTUAL_BALANCES[0]); + expect((await pool.getVirtualBalances())[1]).to.equal(VIRTUAL_BALANCES[1]); + }); }); context('when the creation fails', () => { From 628f987f618cab3324388f8a4982378fb94d8b6d Mon Sep 17 00:00:00 2001 From: dcpp Date: Fri, 5 Sep 2025 11:21:43 +0300 Subject: [PATCH 08/13] Update token number test --- pkg/pool-range/test/BaseRangePool.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/pool-range/test/BaseRangePool.test.ts b/pkg/pool-range/test/BaseRangePool.test.ts index 937e674e1..669382fb4 100644 --- a/pkg/pool-range/test/BaseRangePool.test.ts +++ b/pkg/pool-range/test/BaseRangePool.test.ts @@ -27,7 +27,7 @@ describe('BaseRangePool', function () { context('for a too-many token pool', () => { it('reverts if there are too many tokens', async () => { // The maximum number of tokens is 20 - const tokens = await TokenList.create(21); + const tokens = await TokenList.create(11); const weights = new Array(21).fill(fp(1)); await expect(RangePool.create({ tokens, weights })).to.be.revertedWith('MAX_TOKENS'); From 3fe674a689dea001d4147a78e32b95da154d6a6b Mon Sep 17 00:00:00 2001 From: dcpp Date: Tue, 9 Sep 2025 15:42:04 +0300 Subject: [PATCH 09/13] Change pool join and exit math --- .../pool-range/RangePoolUserData.sol | 25 +++++++ pkg/pool-range/contracts/BaseRangePool.sol | 37 ++++++----- pkg/pool-range/contracts/RangeMath.sol | 35 ++++++---- pkg/pool-range/contracts/RangePool.sol | 26 ++++---- pkg/pool-range/contracts/RangePoolFactory.sol | 2 - .../contracts/RangePoolProtocolFees.sol | 5 +- pkg/pool-range/hardhat.config.ts | 2 +- pkg/pool-range/test/BaseRangePool.behavior.ts | 66 ++++++++++++++----- pkg/pool-range/test/helpers/BaseRangePool.ts | 8 ++- pkg/pool-range/test/helpers/RangePool.ts | 10 +-- pkg/pool-range/test/helpers/TypesConverter.ts | 3 - pkg/pool-range/test/helpers/encoder.ts | 33 ++++++++++ pkg/pool-range/test/helpers/types.ts | 2 +- pkg/pool-utils/test/BasePool.test.ts | 5 +- 14 files changed, 176 insertions(+), 83 deletions(-) create mode 100644 pkg/interfaces/contracts/pool-range/RangePoolUserData.sol create mode 100644 pkg/pool-range/test/helpers/encoder.ts diff --git a/pkg/interfaces/contracts/pool-range/RangePoolUserData.sol b/pkg/interfaces/contracts/pool-range/RangePoolUserData.sol new file mode 100644 index 000000000..ab45e3b1c --- /dev/null +++ b/pkg/interfaces/contracts/pool-range/RangePoolUserData.sol @@ -0,0 +1,25 @@ +// 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 . + +pragma solidity >=0.7.0 <0.9.0; + +import "../pool-weighted/WeightedPoolUserData.sol"; + +library RangePoolUserData { + // Joins + + function initialVirtualBalances(bytes memory self) internal pure returns (uint256[] memory virtualBalances) { + (, , virtualBalances) = abi.decode(self, (WeightedPoolUserData.JoinKind, uint256[], uint256[])); + } +} diff --git a/pkg/pool-range/contracts/BaseRangePool.sol b/pkg/pool-range/contracts/BaseRangePool.sol index 0c6e70649..7b1fcd295 100644 --- a/pkg/pool-range/contracts/BaseRangePool.sol +++ b/pkg/pool-range/contracts/BaseRangePool.sol @@ -16,6 +16,7 @@ pragma solidity ^0.7.0; pragma experimental ABIEncoderV2; import "@balancer-labs/v2-interfaces/contracts/pool-weighted/WeightedPoolUserData.sol"; +import "@balancer-labs/v2-interfaces/contracts/pool-range/RangePoolUserData.sol"; import "@balancer-labs/v2-solidity-utils/contracts/math/FixedPoint.sol"; import "@balancer-labs/v2-solidity-utils/contracts/helpers/InputHelpers.sol"; @@ -35,6 +36,7 @@ abstract contract BaseRangePool is BaseGeneralPool { using FixedPoint for uint256; using BasePoolUserData for bytes; using WeightedPoolUserData for bytes; + using RangePoolUserData for bytes; constructor( IVault vault, @@ -92,24 +94,19 @@ abstract contract BaseRangePool is BaseGeneralPool { function _getVirtualBalance(IERC20 token) internal view virtual returns (uint256); /** - * @dev Increases the virtual balance of `token`. + * @dev Sets the virtual balances of `initialization`. */ - function _increaseVirtualBalance(IERC20 token, uint256 delta) internal virtual; + function _setVirtualBalances(uint256[] memory deltas) internal virtual; /** - * @dev Decreases the virtual balance of `token`. + * @dev Changes the virtual balance of `token`. */ - function _decreaseVirtualBalance(IERC20 token, uint256 delta) internal virtual; + function _changeVirtualBalance(IERC20 token, uint256 delta, bool increase) internal virtual; /** - * @dev Increases the virtual balances of `join`. + * @dev Changes the virtual balances of `join` by ratioMin. */ - function _increaseVirtualBalances(uint256[] memory deltas) internal virtual; - - /** - * @dev Decreases the virtual balances of `exit`. - */ - function _decreaseVirtualBalances(uint256[] memory deltas) internal virtual; + function _changeVirtualBalancesBy(uint256 ratioMin, bool increase) internal virtual; /** * @dev Returns the current value of the invariant. @@ -165,8 +162,8 @@ abstract contract BaseRangePool is BaseGeneralPool { balances[tokenOutIdx] ); - _increaseVirtualBalance(swapRequest.tokenIn, swapRequest.amount); - _decreaseVirtualBalance(swapRequest.tokenOut, amountOut); + _changeVirtualBalance(swapRequest.tokenIn, swapRequest.amount, true); + _changeVirtualBalance(swapRequest.tokenOut, amountOut, false); return amountOut; } @@ -188,8 +185,8 @@ abstract contract BaseRangePool is BaseGeneralPool { swapRequest.amount ); - _increaseVirtualBalance(swapRequest.tokenIn, amountIn); - _decreaseVirtualBalance(swapRequest.tokenOut, swapRequest.amount); + _changeVirtualBalance(swapRequest.tokenIn, amountIn, true); + _changeVirtualBalance(swapRequest.tokenOut, swapRequest.amount, false); return amountIn; } @@ -242,7 +239,9 @@ abstract contract BaseRangePool is BaseGeneralPool { _require(kind == WeightedPoolUserData.JoinKind.INIT, Errors.UNINITIALIZED); uint256[] memory amountsIn = userData.initialAmountsIn(); + uint256[] memory virtualBalances = userData.initialVirtualBalances(); InputHelpers.ensureInputLengthMatch(amountsIn.length, scalingFactors.length); + InputHelpers.ensureInputLengthMatch(amountsIn.length, virtualBalances.length); _upscaleArray(amountsIn, scalingFactors); uint256[] memory normalizedWeights = _getNormalizedWeights(); @@ -254,7 +253,7 @@ abstract contract BaseRangePool is BaseGeneralPool { // Initialization is still a join, so we need to do post-join work. Since we are not paying protocol fees, // and all we need to do is update the invariant, call `_updatePostJoinExit` here instead of `_afterJoinExit`. - _increaseVirtualBalances(amountsIn); + _setVirtualBalances(virtualBalances); _updatePostJoinExit(invariantAfterJoin); @@ -286,7 +285,8 @@ abstract contract BaseRangePool is BaseGeneralPool { userData ); - _increaseVirtualBalances(amountsIn); + uint256 minRatio = bptAmountOut.mulUp(FixedPoint.ONE).divDown(preJoinExitSupply); + _changeVirtualBalancesBy(minRatio, true); _afterJoinExit( preJoinExitInvariant, @@ -384,7 +384,8 @@ abstract contract BaseRangePool is BaseGeneralPool { userData ); - _decreaseVirtualBalances(amountsOut); + uint256 minRatio = bptAmountIn.mulUp(FixedPoint.ONE).divDown(preJoinExitSupply); + _changeVirtualBalancesBy(minRatio, false); _afterJoinExit( preJoinExitInvariant, diff --git a/pkg/pool-range/contracts/RangeMath.sol b/pkg/pool-range/contracts/RangeMath.sol index a6fd09993..da7be10ac 100644 --- a/pkg/pool-range/contracts/RangeMath.sol +++ b/pkg/pool-range/contracts/RangeMath.sol @@ -98,13 +98,7 @@ library RangeMath { uint256[] memory amountsIn, uint256 bptTotalSupply ) internal pure returns (uint256) { - uint256 ratioMin = amountsIn[0].mulUp(FixedPoint.ONE).divDown(factBalances[0]); - uint256 i = 1; - while (i < factBalances.length && ratioMin > 0) { - ratioMin = Math.min(ratioMin, amountsIn[i].mulUp(FixedPoint.ONE).divDown(factBalances[i])); - i++; - } - + uint256 ratioMin = _calcRatioMin(factBalances, amountsIn); return bptTotalSupply.mulUp(ratioMin).divDown(FixedPoint.ONE); } @@ -113,13 +107,28 @@ library RangeMath { uint256[] memory amountsOut, uint256 bptTotalSupply ) internal pure returns (uint256) { - uint256 ratioMin = amountsOut[0].mulUp(FixedPoint.ONE).divDown(factBalances[0]); - uint256 i = 1; - while (i < factBalances.length && ratioMin > 0) { - ratioMin = Math.min(ratioMin, amountsOut[i].mulUp(FixedPoint.ONE).divDown(factBalances[i])); + uint256 ratioMin = _calcRatioMin(factBalances, amountsOut); + return bptTotalSupply.mulUp(ratioMin).divDown(FixedPoint.ONE); + } + + function _calcRatioMin( + uint256[] memory factBalances, + uint256[] memory amounts + ) internal pure returns (uint256) { + uint256 ratioMin = 0; + uint256 i = 0; + while (i < factBalances.length) { + if (factBalances[i] > 0) { + uint256 currentRatio = amounts[i].mulUp(FixedPoint.ONE).divDown(factBalances[i]); + if (ratioMin > 0) { + ratioMin = Math.min(ratioMin, currentRatio); + if (ratioMin == 0) break; + } else { + ratioMin = currentRatio; + } + } i++; } - - return bptTotalSupply.mulUp(ratioMin).divDown(FixedPoint.ONE); + return ratioMin; } } diff --git a/pkg/pool-range/contracts/RangePool.sol b/pkg/pool-range/contracts/RangePool.sol index 2325f0f84..b34f0f9f9 100644 --- a/pkg/pool-range/contracts/RangePool.sol +++ b/pkg/pool-range/contracts/RangePool.sol @@ -72,7 +72,6 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { string symbol; IERC20[] tokens; uint256[] normalizedWeights; - uint256[] virtualBalances; IRateProvider[] rateProviders; address[] assetManagers; uint256 swapFeePercentage; @@ -106,7 +105,6 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { { uint256 numTokens = params.tokens.length; InputHelpers.ensureInputLengthMatch(numTokens, params.normalizedWeights.length); - InputHelpers.ensureInputLengthMatch(numTokens, params.virtualBalances.length); _totalTokens = numTokens; @@ -118,7 +116,7 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { _require(normalizedWeight >= WeightedMath._MIN_WEIGHT, Errors.MIN_WEIGHT); normalizedSum = normalizedSum.add(normalizedWeight); - _virtualBalances.push(params.virtualBalances[i]); + _virtualBalances.push(0); } // Ensure that the normalized weights sum to ONE _require(normalizedSum == FixedPoint.ONE, Errors.NORMALIZED_WEIGHT_INVARIANT); @@ -229,28 +227,26 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { return _virtualBalances[_getTokenIndex(token)]; } - function _increaseVirtualBalance(IERC20 token, uint256 delta) internal virtual override { + function _changeVirtualBalance(IERC20 token, uint256 delta, bool increase) internal virtual override { uint256 tokenIdx = _getTokenIndex(token); - _virtualBalances[tokenIdx] = _virtualBalances[tokenIdx].add(delta); + uint256 downScaledDelta = _downscaleDown(delta, _scalingFactor(token)); + _virtualBalances[tokenIdx] = increase ? _virtualBalances[tokenIdx].add(downScaledDelta) : + _virtualBalances[tokenIdx].sub(downScaledDelta); } - function _decreaseVirtualBalance(IERC20 token, uint256 delta) internal virtual override { - uint256 tokenIdx = _getTokenIndex(token); - _virtualBalances[tokenIdx] = _virtualBalances[tokenIdx].sub(delta); - } - - function _increaseVirtualBalances(uint256[] memory deltas) internal virtual override { + function _setVirtualBalances(uint256[] memory balances) internal virtual override { for (uint256 i = 0; i < _totalTokens; i++) { - _virtualBalances[i] = _virtualBalances[i].add(deltas[i]); + _virtualBalances[i] = balances[i]; } } - function _decreaseVirtualBalances(uint256[] memory deltas) internal virtual override { + function _changeVirtualBalancesBy(uint256 ratioMin, bool increase) internal virtual override { for (uint256 i = 0; i < _totalTokens; i++) { - _virtualBalances[i] = _virtualBalances[i].sub(deltas[i]); + uint256 value = _virtualBalances[i].mulUp(ratioMin).divDown(FixedPoint.ONE); + _virtualBalances[i] = increase ? _virtualBalances[i].add(value) : _virtualBalances[i].sub(value); } } - + /** * @dev Returns the scaling factor for one of the Pool's tokens. Reverts if `token` is not a token registered by the * Pool. diff --git a/pkg/pool-range/contracts/RangePoolFactory.sol b/pkg/pool-range/contracts/RangePoolFactory.sol index ae18d7324..7bdd4e58a 100644 --- a/pkg/pool-range/contracts/RangePoolFactory.sol +++ b/pkg/pool-range/contracts/RangePoolFactory.sol @@ -47,7 +47,6 @@ contract RangePoolFactory is BasePoolFactory { string memory symbol, IERC20[] memory tokens, uint256[] memory normalizedWeights, - uint256[] memory virtualBalances, IRateProvider[] memory rateProviders, uint256 swapFeePercentage, address owner, @@ -63,7 +62,6 @@ contract RangePoolFactory is BasePoolFactory { symbol: symbol, tokens: tokens, normalizedWeights: normalizedWeights, - virtualBalances: virtualBalances, rateProviders: rateProviders, assetManagers: new address[](tokens.length), // Don't allow asset managers, swapFeePercentage: swapFeePercentage diff --git a/pkg/pool-range/contracts/RangePoolProtocolFees.sol b/pkg/pool-range/contracts/RangePoolProtocolFees.sol index 70e1556bc..f868729d3 100644 --- a/pkg/pool-range/contracts/RangePoolProtocolFees.sol +++ b/pkg/pool-range/contracts/RangePoolProtocolFees.sol @@ -110,7 +110,8 @@ abstract contract RangePoolProtocolFees is BaseRangePool, ProtocolFeeCache, IRat } function getRateProviders() external view override returns (IRateProvider[] memory) { - uint256 totalTokens = _getTotalTokens(); + // commented due to contract size exeeds 24k + /*uint256 totalTokens = _getTotalTokens(); IRateProvider[] memory providers = new IRateProvider[](totalTokens); // prettier-ignore @@ -127,7 +128,7 @@ abstract contract RangePoolProtocolFees is BaseRangePool, ProtocolFeeCache, IRat if (totalTokens > 9) { providers[9] = _rateProvider9; } else { return providers; } } - return providers; + return providers;*/ } // Protocol Fees diff --git a/pkg/pool-range/hardhat.config.ts b/pkg/pool-range/hardhat.config.ts index dddadaacd..7997b27d6 100644 --- a/pkg/pool-range/hardhat.config.ts +++ b/pkg/pool-range/hardhat.config.ts @@ -25,7 +25,7 @@ export default { settings: { optimizer: { enabled: true, - runs: 1000, + runs: 1, }, }, }, diff --git a/pkg/pool-range/test/BaseRangePool.behavior.ts b/pkg/pool-range/test/BaseRangePool.behavior.ts index e86cb1220..47357b89f 100644 --- a/pkg/pool-range/test/BaseRangePool.behavior.ts +++ b/pkg/pool-range/test/BaseRangePool.behavior.ts @@ -17,7 +17,7 @@ import RangePool from './helpers/RangePool'; export function itBehavesAsRangePool(numberOfTokens: number): void { const POOL_SWAP_FEE_PERCENTAGE = fp(0.01); const WEIGHTS = [fp(30), fp(70), fp(5), fp(5)]; - const VIRTUAL_BALANCES = [fp(10), fp(20), fp(30), fp(40)]; + const VIRTUAL_BALANCES = [fp(0.2), fp(0.4), fp(0.6), fp(0.8)]; const INITIAL_BALANCES = [fp(0.1), fp(0.2), fp(0.3), fp(0.4)]; const GENERAL_POOL_ONSWAP = 'onSwap((uint8,address,address,uint256,bytes32,uint256,address,address,bytes),uint256[],uint256,uint256)'; @@ -28,7 +28,7 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { const ZEROS = Array(numberOfTokens).fill(bn(0)); const weights: BigNumberish[] = WEIGHTS.slice(0, numberOfTokens); - const virtualBalances: BigNumberish[] = VIRTUAL_BALANCES.slice(0, numberOfTokens); + const initialVirtualBalances: BigNumberish[] = VIRTUAL_BALANCES.slice(0, numberOfTokens); const initialBalances = INITIAL_BALANCES.slice(0, numberOfTokens); async function deployPool(params: RawRangePoolDeployment = {}): Promise { @@ -36,7 +36,6 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { vault, tokens, weights, - virtualBalances, swapFeePercentage: POOL_SWAP_FEE_PERCENTAGE, ...params, }); @@ -120,8 +119,8 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { }); it('sets the virtual balances', async () => { - expect((await pool.getVirtualBalances())[0]).to.equal(VIRTUAL_BALANCES[0]); - expect((await pool.getVirtualBalances())[1]).to.equal(VIRTUAL_BALANCES[1]); + expect((await pool.getVirtualBalances())[0]).to.equal(0); + expect((await pool.getVirtualBalances())[1]).to.equal(0); }); }); @@ -179,7 +178,11 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { it('grants the n * invariant amount of BPT', async () => { const invariant = await pool.estimateInvariant(initialBalances); - const { amountsIn, dueProtocolFeeAmounts } = await pool.init({ recipient, initialBalances, from: lp }); + const { amountsIn, dueProtocolFeeAmounts } = await pool.init({ + initialBalances, + initialVirtualBalances, + recipient, + from: lp }); // Amounts in should be the same as initial ones expect(amountsIn).to.deep.equal(initialBalances); @@ -192,15 +195,25 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { }); it('fails if already initialized', async () => { - await pool.init({ recipient, initialBalances, from: lp }); - - await expect(pool.init({ initialBalances, from: lp })).to.be.revertedWith('UNHANDLED_JOIN_KIND'); + await pool.init({ + initialBalances, + initialVirtualBalances, + recipient, + from: lp }); + + await expect(pool.init({ + initialBalances, + initialVirtualBalances, + from: lp })).to.be.revertedWith('UNHANDLED_JOIN_KIND'); }); it('reverts if paused', async () => { await pool.pause(); - await expect(pool.init({ initialBalances, from: lp })).to.be.revertedWith('PAUSED'); + await expect(pool.init({ + initialBalances, + initialVirtualBalances, + from: lp })).to.be.revertedWith('PAUSED'); }); }); @@ -216,7 +229,11 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { const amountsIn = ZEROS.map((n, i) => (i === 1 ? fp(0.1) : fp(0.2))); sharedBeforeEach('initialize pool', async () => { - await pool.init({ recipient, initialBalances, from: lp }); + await pool.init({ + initialBalances, + initialVirtualBalances, + recipient, + from: lp }); expectedBptOut = await pool.estimateBptOut(amountsIn, initialBalances); }); @@ -274,7 +291,11 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { context('once initialized', () => { sharedBeforeEach('initialize pool', async () => { - await pool.init({ recipient, initialBalances, from: lp }); + await pool.init({ + initialBalances, + initialVirtualBalances, + recipient: recipient, + from: lp }); }); it('grants exact BPT for tokens in', async () => { @@ -464,7 +485,10 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { sharedBeforeEach('deploy and initialize pool', async () => { await deployPool(); - await pool.init({ initialBalances, from: lp }); + await pool.init({ + initialBalances, + initialVirtualBalances, + from: lp }); previousBptBalance = await pool.balanceOf(lp); }); @@ -622,7 +646,10 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { sharedBeforeEach('deploy and join pool', async () => { await deployPool(); - await pool.init({ initialBalances, from: lp }); + await pool.init({ + initialBalances, + initialVirtualBalances, + from: lp }); }); context('when not in recovery mode', () => { @@ -641,7 +668,10 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { describe('recovery mode', () => { sharedBeforeEach('deploy pool and enter recovery mode', async () => { await deployPool(); - await pool.init({ initialBalances, from: lp }); + await pool.init({ + initialBalances, + initialVirtualBalances, + from: lp }); await pool.enableRecoveryMode(); }); @@ -689,7 +719,11 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { sharedBeforeEach('deploy and join pool', async () => { // We will use a mock vault for this one since we'll need to manipulate balances. await deployPool({ vault: await Vault.create({ mocked: true }) }); - await pool.init({ initialBalances, from: lp, protocolFeePercentage }); + await pool.init({ + initialBalances, + initialVirtualBalances, + from: lp, + protocolFeePercentage }); }); context('without balance changes', () => { diff --git a/pkg/pool-range/test/helpers/BaseRangePool.ts b/pkg/pool-range/test/helpers/BaseRangePool.ts index 401bc25b3..3d75f9115 100644 --- a/pkg/pool-range/test/helpers/BaseRangePool.ts +++ b/pkg/pool-range/test/helpers/BaseRangePool.ts @@ -34,6 +34,7 @@ import { } from '@balancer-labs/v2-helpers/src/models/pools/weighted/math'; import { SwapKind, WeightedPoolEncoder } from '@balancer-labs/balancer-js'; +import { RangePoolEncoder } from './encoder'; import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; import BasePool from '@balancer-labs/v2-helpers/src/models/pools/base/BasePool'; import { Account } from '@balancer-labs/v2-helpers/src/models/types/types'; @@ -394,14 +395,15 @@ export default class BaseRangePool extends BasePool { } private _buildInitParams(params: InitRangePool): JoinExitRangePool { - const { initialBalances: balances } = params; + const { initialBalances: balances, initialVirtualBalances: virtualBalances } = params; const amountsIn = Array.isArray(balances) ? balances : Array(this.tokens.length).fill(balances); - + const vBalances = Array.isArray(virtualBalances) ? virtualBalances : Array(this.tokens.length).fill(virtualBalances); + return { from: params.from, recipient: params.recipient, protocolFeePercentage: params.protocolFeePercentage, - data: WeightedPoolEncoder.joinInit(amountsIn), + data: RangePoolEncoder.joinInit(amountsIn, vBalances), }; } diff --git a/pkg/pool-range/test/helpers/RangePool.ts b/pkg/pool-range/test/helpers/RangePool.ts index d8efc6e6a..020ed1b8d 100644 --- a/pkg/pool-range/test/helpers/RangePool.ts +++ b/pkg/pool-range/test/helpers/RangePool.ts @@ -28,13 +28,12 @@ export default class RangePool extends BaseRangePool { vault: Vault, tokens: TokenList, weights: BigNumberish[], - virtualBalances: BigNumberish[], rateProviders: Account[], assetManagers: string[], swapFeePercentage: BigNumberish, owner?: Account ) { - super(instance, poolId, vault, tokens, weights, virtualBalances, swapFeePercentage, owner); + super(instance, poolId, vault, tokens, weights, swapFeePercentage, owner); this.rateProviders = rateProviders; this.assetManagers = assetManagers; @@ -46,7 +45,7 @@ export default class RangePool extends BaseRangePool { const pool = await (params.fromFactory ? this._deployFromFactory : this._deployStandalone)(deployment, vault); const poolId = await pool.getPoolId(); - const { tokens, weights, virtualBalances, rateProviders, assetManagers, swapFeePercentage, owner } = deployment; + const { tokens, weights, rateProviders, assetManagers, swapFeePercentage, owner } = deployment; return new RangePool( pool, @@ -54,7 +53,6 @@ export default class RangePool extends BaseRangePool { vault, tokens, weights, - virtualBalances, rateProviders, assetManagers, swapFeePercentage, @@ -72,7 +70,6 @@ export default class RangePool extends BaseRangePool { symbol: SYMBOL, tokens: params.tokens.addresses, normalizedWeights: params.weights, - virtualBalances: params.virtualBalances, rateProviders: TypesConverter.toAddresses(params.rateProviders), assetManagers: params.assetManagers, swapFeePercentage: params.swapFeePercentage, @@ -90,7 +87,7 @@ export default class RangePool extends BaseRangePool { static async _deployFromFactory(params: RangePoolDeployment, vault: Vault): Promise { // Note that we only support asset managers with the standalone deploy method. - const { tokens, weights, virtualBalances, rateProviders, swapFeePercentage, owner, from } = params; + const { tokens, weights, rateProviders, swapFeePercentage, owner, from } = params; const factory = await deploy('RangePoolFactory', { args: [vault.address, vault.getFeesProvider().address, PAUSE_WINDOW_DURATION, BUFFER_PERIOD_DURATION], @@ -102,7 +99,6 @@ export default class RangePool extends BaseRangePool { SYMBOL, tokens.addresses, weights, - virtualBalances, rateProviders, swapFeePercentage, owner, diff --git a/pkg/pool-range/test/helpers/TypesConverter.ts b/pkg/pool-range/test/helpers/TypesConverter.ts index 2095ba9ca..f15efdcb4 100644 --- a/pkg/pool-range/test/helpers/TypesConverter.ts +++ b/pkg/pool-range/test/helpers/TypesConverter.ts @@ -23,7 +23,6 @@ export default { let { tokens, weights, - virtualBalances, rateProviders, assetManagers, swapFeePercentage, @@ -34,7 +33,6 @@ export default { if (!tokens) tokens = new TokenList(); if (!weights) weights = Array(tokens.length).fill(fp(1)); weights = toNormalizedWeights(weights.map(bn)); - if (!virtualBalances) virtualBalances = Array(tokens.length).fill(0); if (!swapFeePercentage) swapFeePercentage = bn(1e16); if (!pauseWindowDuration) pauseWindowDuration = DEFAULT_PAUSE_WINDOW_DURATION; if (!bufferPeriodDuration) bufferPeriodDuration = DEFAULT_BUFFER_PERIOD_DURATION; @@ -44,7 +42,6 @@ export default { return { tokens, weights, - virtualBalances, rateProviders, assetManagers, swapFeePercentage, diff --git a/pkg/pool-range/test/helpers/encoder.ts b/pkg/pool-range/test/helpers/encoder.ts new file mode 100644 index 000000000..03ad8c003 --- /dev/null +++ b/pkg/pool-range/test/helpers/encoder.ts @@ -0,0 +1,33 @@ +import { defaultAbiCoder } from '@ethersproject/abi'; +import { BigNumberish } from '@ethersproject/bignumber'; + +export enum RangePoolJoinKind { + INIT = 0, + EXACT_TOKENS_IN_FOR_BPT_OUT, + TOKEN_IN_FOR_EXACT_BPT_OUT, + ALL_TOKENS_IN_FOR_EXACT_BPT_OUT, + ADD_TOKEN, +} + +export enum RangePoolExitKind { + EXACT_BPT_IN_FOR_ONE_TOKEN_OUT = 0, + EXACT_BPT_IN_FOR_TOKENS_OUT, + BPT_IN_FOR_EXACT_TOKENS_OUT, + REMOVE_TOKEN, +} + +export class RangePoolEncoder { + /** + * Cannot be constructed. + */ + private constructor() { + // eslint-disable-next-line @typescript-eslint/no-empty-function + } + + /** + * Encodes the userData parameter for providing the initial liquidity to a WeightedPool + * @param initialBalances - the amounts of tokens to send to the pool to form the initial balances + */ + static joinInit = (amountsIn: BigNumberish[], vBalances: BigNumberish[]): string => + defaultAbiCoder.encode(['uint256', 'uint256[]', 'uint256[]'], [RangePoolJoinKind.INIT, amountsIn, vBalances]); +} diff --git a/pkg/pool-range/test/helpers/types.ts b/pkg/pool-range/test/helpers/types.ts index eeb49c75d..1301e700e 100644 --- a/pkg/pool-range/test/helpers/types.ts +++ b/pkg/pool-range/test/helpers/types.ts @@ -27,7 +27,6 @@ export type RawRangePoolDeployment = { export type RangePoolDeployment = { tokens: TokenList; weights: BigNumberish[]; - virtualBalances: BigNumberish[]; rateProviders: Account[]; assetManagers: string[]; swapFeePercentage: BigNumberish; @@ -60,6 +59,7 @@ export type JoinExitRangePool = { export type InitRangePool = { initialBalances: NAry; + initialVirtualBalances: NAry; from?: SignerWithAddress; recipient?: Account; protocolFeePercentage?: BigNumberish; diff --git a/pkg/pool-utils/test/BasePool.test.ts b/pkg/pool-utils/test/BasePool.test.ts index 59e154f0e..a37b508f3 100644 --- a/pkg/pool-utils/test/BasePool.test.ts +++ b/pkg/pool-utils/test/BasePool.test.ts @@ -8,7 +8,7 @@ import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; import { advanceTime, DAY, MONTH } from '@balancer-labs/v2-helpers/src/time'; import { actionId } from '@balancer-labs/v2-helpers/src/models/misc/actions'; import { deploy, deployedAt } from '@balancer-labs/v2-helpers/src/contract'; -import { JoinPoolRequest, ExitPoolRequest, PoolSpecialization, WeightedPoolEncoder } from '@balancer-labs/balancer-js'; +import { JoinPoolRequest, ExitPoolRequest, PoolSpecialization, RangePoolEncoder } from '@balancer-labs/balancer-js'; import { BigNumberish, fp } from '@balancer-labs/v2-helpers/src/numbers'; import { ANY_ADDRESS, DELEGATE_OWNER, ZERO_ADDRESS } from '@balancer-labs/v2-helpers/src/constants'; import { Account } from '@balancer-labs/v2-helpers/src/models/types/types'; @@ -306,12 +306,13 @@ describe('BasePool', function () { sharedBeforeEach('deploy and initialize pool', async () => { initialBalances = Array(tokens.length).fill(fp(1000)); + initialVirtualBalances = Array(tokens.length).fill(fp(2000)); poolId = await pool.getPoolId(); const request: JoinPoolRequest = { assets: tokens.addresses, maxAmountsIn: initialBalances, - userData: WeightedPoolEncoder.joinInit(initialBalances), + userData: RangePoolEncoder.joinInit(initialBalances, initialVirtualBalances), fromInternalBalance: false, }; From b421aefe5321681d023ebc0addc4a150b2450253 Mon Sep 17 00:00:00 2001 From: dcpp Date: Thu, 11 Sep 2025 22:40:51 +0300 Subject: [PATCH 10/13] Fix testcase: Swap all amount of single token and join again --- pkg/pool-range/contracts/BaseRangePool.sol | 26 +++++++------------ .../contracts/RangeInputHelpers.sol | 26 +++++++++++++++++++ pkg/pool-range/contracts/RangePool.sol | 4 +-- pkg/pool-range/test/BaseRangePool.behavior.ts | 22 ++++++++++------ pkg/pool-range/test/helpers/BaseRangePool.ts | 4 +-- 5 files changed, 53 insertions(+), 29 deletions(-) create mode 100644 pkg/pool-range/contracts/RangeInputHelpers.sol diff --git a/pkg/pool-range/contracts/BaseRangePool.sol b/pkg/pool-range/contracts/BaseRangePool.sol index 7b1fcd295..d71028bc9 100644 --- a/pkg/pool-range/contracts/BaseRangePool.sol +++ b/pkg/pool-range/contracts/BaseRangePool.sol @@ -25,6 +25,7 @@ import "@balancer-labs/v2-pool-utils/contracts/BaseGeneralPool.sol"; import "@balancer-labs/v2-pool-utils/contracts/lib/BasePoolMath.sol"; import "@balancer-labs/v2-pool-weighted/contracts/WeightedMath.sol"; +import "./RangeInputHelpers.sol"; import "./RangeMath.sol"; /** @@ -123,7 +124,7 @@ abstract contract BaseRangePool is BaseGeneralPool { * See https://forum.balancer.fi/t/reentrancy-vulnerability-scope-expanded/4345 for reference. */ function getInvariant() public view returns (uint256) { - (, uint256[] memory balances, ) = getVault().getPoolTokens(getPoolId()); + uint256[] memory balances = _getVirtualBalances(); // Since the Pool hooks always work with upscaled balances, we manually // upscale here for consistency @@ -194,13 +195,10 @@ abstract contract BaseRangePool is BaseGeneralPool { * @dev Called before any join or exit operation. Returns the Pool's total supply by default, but derived contracts * may choose to add custom behavior at these steps. This often has to do with protocol fee processing. */ - function _beforeJoinExit(uint256[] memory preBalances, uint256[] memory normalizedWeights) + function _beforeJoinExit(uint256[] memory /*preBalances*/, uint256[] memory /*normalizedWeights*/) internal virtual - returns (uint256, uint256) - { - return (totalSupply(), WeightedMath._calculateInvariant(normalizedWeights, preBalances)); - } + returns (uint256, uint256); /** * @dev Called after any regular join or exit operation. Empty by default, but derived contracts @@ -217,14 +215,10 @@ abstract contract BaseRangePool is BaseGeneralPool { uint256[] memory normalizedWeights, uint256 preJoinExitSupply, uint256 postJoinExitSupply - ) internal virtual { - // solhint-disable-previous-line no-empty-blocks - } + ) internal virtual; // Derived contracts may call this to update state after a join or exit. - function _updatePostJoinExit(uint256 postJoinExitInvariant) internal virtual { - // solhint-disable-previous-line no-empty-blocks - } + function _updatePostJoinExit(uint256 postJoinExitInvariant) internal virtual; // Initialize @@ -241,11 +235,11 @@ abstract contract BaseRangePool is BaseGeneralPool { uint256[] memory amountsIn = userData.initialAmountsIn(); uint256[] memory virtualBalances = userData.initialVirtualBalances(); InputHelpers.ensureInputLengthMatch(amountsIn.length, scalingFactors.length); - InputHelpers.ensureInputLengthMatch(amountsIn.length, virtualBalances.length); + RangeInputHelpers.ensureFactBalanceIsLessOrEqual(amountsIn, virtualBalances); _upscaleArray(amountsIn, scalingFactors); - uint256[] memory normalizedWeights = _getNormalizedWeights(); - uint256 invariantAfterJoin = WeightedMath._calculateInvariant(normalizedWeights, amountsIn); + _setVirtualBalances(virtualBalances); + uint256 invariantAfterJoin = getInvariant(); // Set the initial BPT to the value of the invariant times the number of tokens. This makes BPT supply more // consistent in Pools with similar compositions but different number of tokens. @@ -253,8 +247,6 @@ abstract contract BaseRangePool is BaseGeneralPool { // Initialization is still a join, so we need to do post-join work. Since we are not paying protocol fees, // and all we need to do is update the invariant, call `_updatePostJoinExit` here instead of `_afterJoinExit`. - _setVirtualBalances(virtualBalances); - _updatePostJoinExit(invariantAfterJoin); return (bptAmountOut, amountsIn); diff --git a/pkg/pool-range/contracts/RangeInputHelpers.sol b/pkg/pool-range/contracts/RangeInputHelpers.sol new file mode 100644 index 000000000..d829b9f25 --- /dev/null +++ b/pkg/pool-range/contracts/RangeInputHelpers.sol @@ -0,0 +1,26 @@ +// 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 . + +pragma solidity ^0.7.0; + +import "@balancer-labs/v2-solidity-utils/contracts/helpers/InputHelpers.sol"; + +library RangeInputHelpers { + function ensureFactBalanceIsLessOrEqual(uint256[] memory a, uint256[] memory b) internal pure { + InputHelpers.ensureInputLengthMatch(a.length, b.length); + for (uint256 i = 0; i < a.length; ++i) { + _require(a[i] <= b[i], Errors.INSUFFICIENT_BALANCE); + } + } +} diff --git a/pkg/pool-range/contracts/RangePool.sol b/pkg/pool-range/contracts/RangePool.sol index b34f0f9f9..551c4730b 100644 --- a/pkg/pool-range/contracts/RangePool.sol +++ b/pkg/pool-range/contracts/RangePool.sol @@ -307,14 +307,14 @@ contract RangePool is BaseRangePool, RangePoolProtocolFees { // WeightedPoolProtocolFees functions - function _beforeJoinExit(uint256[] memory preBalances, uint256[] memory normalizedWeights) + function _beforeJoinExit(uint256[] memory /*preBalances*/, uint256[] memory normalizedWeights) internal virtual override returns (uint256, uint256) { uint256 supplyBeforeFeeCollection = totalSupply(); - uint256 invariant = WeightedMath._calculateInvariant(normalizedWeights, preBalances); + uint256 invariant = getInvariant(); (uint256 protocolFeesToBeMinted, uint256 athRateProduct) = _getPreJoinExitProtocolFees( invariant, normalizedWeights, diff --git a/pkg/pool-range/test/BaseRangePool.behavior.ts b/pkg/pool-range/test/BaseRangePool.behavior.ts index 47357b89f..da272349f 100644 --- a/pkg/pool-range/test/BaseRangePool.behavior.ts +++ b/pkg/pool-range/test/BaseRangePool.behavior.ts @@ -176,7 +176,7 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { context('initialization', () => { it('grants the n * invariant amount of BPT', async () => { - const invariant = await pool.estimateInvariant(initialBalances); + const invariant = await pool.estimateInvariant(initialVirtualBalances); const { amountsIn, dueProtocolFeeAmounts } = await pool.init({ initialBalances, @@ -549,14 +549,20 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { expect(result.amount).to.be.equalWithError(expectedAmountOut, 0.05); }); - // excluded since max in ratio is not checked - /*it('reverts if token in exceeds max in ratio', async () => { - const maxAmountIn = await pool.getMaxIn(1); - const maxAmountInWithFees = fpMul(maxAmountIn, POOL_SWAP_FEE_PERCENTAGE.add(fp(1))); + it('swap all amount of token#0 and join tokens again', async () => { + expect((await pool.getBalances())[0]).to.be.gt(0); - const amount = maxAmountInWithFees.add(fp(1)); - await expect(pool.swapGivenIn({ in: 1, out: 0, amount, from: lp })).to.be.revertedWith('MAX_IN_RATIO'); - });*/ + const maxAmountOut = await pool.getMaxOut(0); + await pool.swapGivenOut({ in: 1, out: 0, amount: maxAmountOut, from: lp, recipient }); + + expect((await pool.getBalances())[0]).to.be.eq(0); + + const amountsIn = ZEROS.map((n, i) => (i === 1 ? fp(0.1) : fp(0.2))); + const minimumBptOut = bn(1); + await pool.joinGivenIn({ amountsIn, minimumBptOut, from: lp }); + + expect((await pool.getBalances())[0]).to.be.gt(0); + }); it('reverts if token in is not in the pool', async () => { await expect(pool.swapGivenIn({ in: allTokens.GRT, out: 0, amount: 1, from: lp })).to.be.revertedWith( diff --git a/pkg/pool-range/test/helpers/BaseRangePool.ts b/pkg/pool-range/test/helpers/BaseRangePool.ts index 3d75f9115..5872c774f 100644 --- a/pkg/pool-range/test/helpers/BaseRangePool.ts +++ b/pkg/pool-range/test/helpers/BaseRangePool.ts @@ -81,12 +81,12 @@ export default class BaseRangePool extends BasePool { async getMaxIn(tokenIndex: number, currentBalances?: BigNumber[]): Promise { if (!currentBalances) currentBalances = await this.getBalances(); - return fpMul(currentBalances[tokenIndex], MAX_IN_RATIO); + return currentBalances[tokenIndex]; } async getMaxOut(tokenIndex: number, currentBalances?: BigNumber[]): Promise { if (!currentBalances) currentBalances = await this.getBalances(); - return fpMul(currentBalances[tokenIndex], MAX_OUT_RATIO); + return currentBalances[tokenIndex]; } async getNormalizedWeights(): Promise { From 0a8095408d1963480b22030d7f1b5987e506bbb6 Mon Sep 17 00:00:00 2001 From: dcpp Date: Mon, 15 Sep 2025 12:10:20 +0300 Subject: [PATCH 11/13] Fix 'zeroed token balance' join and exit --- pkg/pool-range/contracts/BaseRangePool.sol | 84 +++++++++---------- .../contracts/ExternalRangeMath.sol | 27 +++--- pkg/pool-range/contracts/RangeMath.sol | 28 +++---- pkg/pool-range/test/BaseRangePool.behavior.ts | 67 ++++++++++++++- pkg/pool-range/test/helpers/BaseRangePool.ts | 4 +- 5 files changed, 139 insertions(+), 71 deletions(-) diff --git a/pkg/pool-range/contracts/BaseRangePool.sol b/pkg/pool-range/contracts/BaseRangePool.sol index d71028bc9..77e3e31d6 100644 --- a/pkg/pool-range/contracts/BaseRangePool.sol +++ b/pkg/pool-range/contracts/BaseRangePool.sol @@ -124,14 +124,14 @@ abstract contract BaseRangePool is BaseGeneralPool { * See https://forum.balancer.fi/t/reentrancy-vulnerability-scope-expanded/4345 for reference. */ function getInvariant() public view returns (uint256) { - uint256[] memory balances = _getVirtualBalances(); + uint256[] memory virtualBalances = _getVirtualBalances(); // Since the Pool hooks always work with upscaled balances, we manually // upscale here for consistency - _upscaleArray(balances, _scalingFactors()); + _upscaleArray(virtualBalances, _scalingFactors()); uint256[] memory normalizedWeights = _getNormalizedWeights(); - return WeightedMath._calculateInvariant(normalizedWeights, balances); + return WeightedMath._calculateInvariant(normalizedWeights, virtualBalances); } function getNormalizedWeights() external view returns (uint256[] memory) { @@ -148,19 +148,19 @@ abstract contract BaseRangePool is BaseGeneralPool { function _onSwapGivenIn( SwapRequest memory swapRequest, - uint256[] memory balances, + uint256[] memory factBalances, uint256 /*indexIn*/, uint256 /*indexOut*/ ) internal virtual override returns (uint256) { uint256 tokenOutIdx = _getTokenIndex(swapRequest.tokenOut); - _require(tokenOutIdx < balances.length, Errors.OUT_OF_BOUNDS); + _require(tokenOutIdx < factBalances.length, Errors.OUT_OF_BOUNDS); uint256 amountOut = RangeMath._calcOutGivenIn( _getVirtualBalance(swapRequest.tokenIn), _getNormalizedWeight(swapRequest.tokenIn), _getVirtualBalance(swapRequest.tokenOut), _getNormalizedWeight(swapRequest.tokenOut), swapRequest.amount, - balances[tokenOutIdx] + factBalances[tokenOutIdx] ); _changeVirtualBalance(swapRequest.tokenIn, swapRequest.amount, true); @@ -170,13 +170,13 @@ abstract contract BaseRangePool is BaseGeneralPool { function _onSwapGivenOut( SwapRequest memory swapRequest, - uint256[] memory balances, + uint256[] memory factBalances, uint256 /*indexIn*/, uint256 /*indexOut*/ ) internal virtual override returns (uint256) { uint256 tokenOutIdx = _getTokenIndex(swapRequest.tokenOut); - _require(tokenOutIdx < balances.length, Errors.OUT_OF_BOUNDS); - _require(balances[tokenOutIdx] >= swapRequest.amount, Errors.INSUFFICIENT_BALANCE); + _require(tokenOutIdx < factBalances.length, Errors.OUT_OF_BOUNDS); + _require(factBalances[tokenOutIdx] >= swapRequest.amount, Errors.INSUFFICIENT_BALANCE); uint256 amountIn = RangeMath._calcInGivenOut( _getVirtualBalance(swapRequest.tokenIn), @@ -210,7 +210,7 @@ abstract contract BaseRangePool is BaseGeneralPool { */ function _afterJoinExit( uint256 preJoinExitInvariant, - uint256[] memory preBalances, + uint256[] memory preBalances, // virtual balances uint256[] memory balanceDeltas, uint256[] memory normalizedWeights, uint256 preJoinExitSupply, @@ -258,7 +258,7 @@ abstract contract BaseRangePool is BaseGeneralPool { bytes32, address sender, address, - uint256[] memory balances, + uint256[] memory factBalances, uint256, uint256, uint256[] memory scalingFactors, @@ -266,29 +266,29 @@ abstract contract BaseRangePool is BaseGeneralPool { ) internal virtual override returns (uint256, uint256[] memory) { uint256[] memory normalizedWeights = _getNormalizedWeights(); - (uint256 preJoinExitSupply, uint256 preJoinExitInvariant) = _beforeJoinExit(balances, normalizedWeights); + (uint256 preJoinExitSupply, uint256 preJoinExitInvariant) = _beforeJoinExit(factBalances, normalizedWeights); (uint256 bptAmountOut, uint256[] memory amountsIn) = _doJoin( sender, - balances, + factBalances, normalizedWeights, scalingFactors, preJoinExitSupply, userData ); - uint256 minRatio = bptAmountOut.mulUp(FixedPoint.ONE).divDown(preJoinExitSupply); - _changeVirtualBalancesBy(minRatio, true); - _afterJoinExit( preJoinExitInvariant, - balances, + _getVirtualBalances(), amountsIn, normalizedWeights, preJoinExitSupply, preJoinExitSupply.add(bptAmountOut) ); + uint256 minRatio = bptAmountOut.mulUp(FixedPoint.ONE).divDown(preJoinExitSupply); + _changeVirtualBalancesBy(minRatio, true); + return (bptAmountOut, amountsIn); } @@ -299,7 +299,7 @@ abstract contract BaseRangePool is BaseGeneralPool { */ function _doJoin( address, - uint256[] memory balances, + uint256[] memory factBalances, uint256[] memory /*normalizedWeights*/, uint256[] memory scalingFactors, uint256 totalSupply, @@ -308,27 +308,27 @@ abstract contract BaseRangePool is BaseGeneralPool { WeightedPoolUserData.JoinKind kind = userData.joinKind(); if (kind == WeightedPoolUserData.JoinKind.EXACT_TOKENS_IN_FOR_BPT_OUT) { - return _joinExactTokensInForBPTOut(balances, scalingFactors, totalSupply, userData); + return _joinExactTokensInForBPTOut(factBalances, scalingFactors, totalSupply, userData); } else if (kind == WeightedPoolUserData.JoinKind.ALL_TOKENS_IN_FOR_EXACT_BPT_OUT) { - return _joinAllTokensInForExactBPTOut(balances, totalSupply, userData); + return _joinAllTokensInForExactBPTOut(factBalances, totalSupply, userData); } else { _revert(Errors.UNHANDLED_JOIN_KIND); } } function _joinExactTokensInForBPTOut( - uint256[] memory balances, + uint256[] memory factBalances, uint256[] memory scalingFactors, uint256 totalSupply, bytes memory userData ) private pure returns (uint256, uint256[] memory) { (uint256[] memory amountsIn, uint256 minBPTAmountOut) = userData.exactTokensInForBptOut(); - InputHelpers.ensureInputLengthMatch(balances.length, amountsIn.length); + InputHelpers.ensureInputLengthMatch(factBalances.length, amountsIn.length); _upscaleArray(amountsIn, scalingFactors); uint256 bptAmountOut = RangeMath._calcBptOutGivenExactTokensIn( - balances, + factBalances, amountsIn, totalSupply ); @@ -339,14 +339,14 @@ abstract contract BaseRangePool is BaseGeneralPool { } function _joinAllTokensInForExactBPTOut( - uint256[] memory balances, + uint256[] memory factBalances, uint256 totalSupply, bytes memory userData ) private pure returns (uint256, uint256[] memory) { uint256 bptAmountOut = userData.allTokensInForExactBptOut(); // Note that there is no maximum amountsIn parameter: this is handled by `IVault.joinPool`. - uint256[] memory amountsIn = BasePoolMath.computeProportionalAmountsIn(balances, totalSupply, bptAmountOut); + uint256[] memory amountsIn = BasePoolMath.computeProportionalAmountsIn(factBalances, totalSupply, bptAmountOut); return (bptAmountOut, amountsIn); } @@ -357,7 +357,7 @@ abstract contract BaseRangePool is BaseGeneralPool { bytes32, address sender, address, - uint256[] memory balances, + uint256[] memory factBalances, uint256, uint256, uint256[] memory scalingFactors, @@ -365,29 +365,29 @@ abstract contract BaseRangePool is BaseGeneralPool { ) internal virtual override returns (uint256, uint256[] memory) { uint256[] memory normalizedWeights = _getNormalizedWeights(); - (uint256 preJoinExitSupply, uint256 preJoinExitInvariant) = _beforeJoinExit(balances, normalizedWeights); + (uint256 preJoinExitSupply, uint256 preJoinExitInvariant) = _beforeJoinExit(factBalances, normalizedWeights); (uint256 bptAmountIn, uint256[] memory amountsOut) = _doExit( sender, - balances, + factBalances, normalizedWeights, scalingFactors, preJoinExitSupply, userData ); - uint256 minRatio = bptAmountIn.mulUp(FixedPoint.ONE).divDown(preJoinExitSupply); - _changeVirtualBalancesBy(minRatio, false); - _afterJoinExit( preJoinExitInvariant, - balances, + _getVirtualBalances(), amountsOut, normalizedWeights, preJoinExitSupply, preJoinExitSupply.sub(bptAmountIn) ); + uint256 minRatio = bptAmountIn.mulUp(FixedPoint.ONE).divDown(preJoinExitSupply); + _changeVirtualBalancesBy(minRatio, false); + return (bptAmountIn, amountsOut); } @@ -398,7 +398,7 @@ abstract contract BaseRangePool is BaseGeneralPool { */ function _doExit( address, - uint256[] memory balances, + uint256[] memory factBalances, uint256[] memory /*normalizedWeights*/, uint256[] memory scalingFactors, uint256 totalSupply, @@ -407,39 +407,39 @@ abstract contract BaseRangePool is BaseGeneralPool { WeightedPoolUserData.ExitKind kind = userData.exitKind(); if (kind == WeightedPoolUserData.ExitKind.EXACT_BPT_IN_FOR_TOKENS_OUT) { - return _exitExactBPTInForTokensOut(balances, totalSupply, userData); + return _exitExactBPTInForTokensOut(factBalances, totalSupply, userData); } else if (kind == WeightedPoolUserData.ExitKind.BPT_IN_FOR_EXACT_TOKENS_OUT) { - return _exitBPTInForExactTokensOut(balances, scalingFactors, totalSupply, userData); + return _exitBPTInForExactTokensOut(factBalances, scalingFactors, totalSupply, userData); } else { _revert(Errors.UNHANDLED_EXIT_KIND); } } function _exitExactBPTInForTokensOut( - uint256[] memory balances, + uint256[] memory factBalances, uint256 totalSupply, bytes memory userData ) private pure returns (uint256, uint256[] memory) { uint256 bptAmountIn = userData.exactBptInForTokensOut(); // Note that there is no minimum amountOut parameter: this is handled by `IVault.exitPool`. - uint256[] memory amountsOut = BasePoolMath.computeProportionalAmountsOut(balances, totalSupply, bptAmountIn); + uint256[] memory amountsOut = BasePoolMath.computeProportionalAmountsOut(factBalances, totalSupply, bptAmountIn); return (bptAmountIn, amountsOut); } function _exitBPTInForExactTokensOut( - uint256[] memory balances, + uint256[] memory factBalances, uint256[] memory scalingFactors, uint256 totalSupply, bytes memory userData ) private pure returns (uint256, uint256[] memory) { (uint256[] memory amountsOut, uint256 maxBPTAmountIn) = userData.bptInForExactTokensOut(); - InputHelpers.ensureInputLengthMatch(amountsOut.length, balances.length); + InputHelpers.ensureInputLengthMatch(amountsOut.length, factBalances.length); _upscaleArray(amountsOut, scalingFactors); // This is an exceptional situation in which the fee is charged on a token out instead of a token in. uint256 bptAmountIn = RangeMath._calcBptInGivenExactTokensOut( - balances, + factBalances, amountsOut, totalSupply ); @@ -451,11 +451,11 @@ abstract contract BaseRangePool is BaseGeneralPool { // Recovery Mode function _doRecoveryModeExit( - uint256[] memory balances, + uint256[] memory factBalances, uint256 totalSupply, bytes memory userData ) internal pure override returns (uint256 bptAmountIn, uint256[] memory amountsOut) { bptAmountIn = userData.recoveryModeExit(); - amountsOut = BasePoolMath.computeProportionalAmountsOut(balances, totalSupply, bptAmountIn); + amountsOut = BasePoolMath.computeProportionalAmountsOut(factBalances, totalSupply, bptAmountIn); } } diff --git a/pkg/pool-range/contracts/ExternalRangeMath.sol b/pkg/pool-range/contracts/ExternalRangeMath.sol index 281311819..08160e3d3 100644 --- a/pkg/pool-range/contracts/ExternalRangeMath.sol +++ b/pkg/pool-range/contracts/ExternalRangeMath.sol @@ -22,49 +22,56 @@ import "./RangeMath.sol"; */ contract ExternalRangeMath { function calcOutGivenIn( - uint256 balanceIn, + uint256 virtualBalanceIn, uint256 weightIn, - uint256 balanceOut, + uint256 virtualBalanceOut, uint256 weightOut, uint256 amountIn, uint256 factBalance ) external pure returns (uint256) { - return RangeMath._calcOutGivenIn(balanceIn, weightIn, balanceOut, weightOut, amountIn, factBalance); + return RangeMath._calcOutGivenIn(virtualBalanceIn, weightIn, virtualBalanceOut, weightOut, amountIn, factBalance); } function calcInGivenOut( - uint256 balanceIn, + uint256 virtualBalanceIn, uint256 weightIn, - uint256 balanceOut, + uint256 virtualBalanceOut, uint256 weightOut, uint256 amountOut ) external pure returns (uint256) { - return RangeMath._calcInGivenOut(balanceIn, weightIn, balanceOut, weightOut, amountOut); + return RangeMath._calcInGivenOut(virtualBalanceIn, weightIn, virtualBalanceOut, weightOut, amountOut); } function calcBptOutGivenExactTokensIn( - uint256[] memory balances, + uint256[] memory factBalances, uint256[] memory amountsIn, uint256 bptTotalSupply ) external pure returns (uint256) { return RangeMath._calcBptOutGivenExactTokensIn( - balances, + factBalances, amountsIn, bptTotalSupply ); } function calcBptInGivenExactTokensOut( - uint256[] memory balances, + uint256[] memory factBalances, uint256[] memory amountsOut, uint256 bptTotalSupply ) external pure returns (uint256) { return RangeMath._calcBptInGivenExactTokensOut( - balances, + factBalances, amountsOut, bptTotalSupply ); } + + function calcRatioMin( + uint256[] memory factBalances, + uint256[] memory amounts + ) external pure returns (uint256) { + return RangeMath._calcRatioMin(factBalances, amounts); + } } diff --git a/pkg/pool-range/contracts/RangeMath.sol b/pkg/pool-range/contracts/RangeMath.sol index da7be10ac..74624c60a 100644 --- a/pkg/pool-range/contracts/RangeMath.sol +++ b/pkg/pool-range/contracts/RangeMath.sol @@ -27,9 +27,9 @@ library RangeMath { // Computes how many tokens can be taken out of a pool if `amountIn` are sent, given the // virtual balances and weights. function _calcOutGivenIn( - uint256 balanceIn, + uint256 virtualBalanceIn, uint256 weightIn, - uint256 balanceOut, + uint256 virtualBalanceOut, uint256 weightOut, uint256 amountIn, uint256 factBalance @@ -37,8 +37,8 @@ library RangeMath { /********************************************************************************************** // outGivenIn // // aO = amountOut // - // bO = balanceOut // - // bI = balanceIn / / bI \ (wI / wO) \ // + // bO = virtualBalanceOut // + // bI = virtualBalanceIn / / bI \ (wI / wO) \ // // aI = amountIn aO = bO * | 1 - | -------------------------- | ^ | // // wI = weightIn \ \ ( bI + aI ) / / // // wO = weightOut // @@ -50,28 +50,28 @@ library RangeMath { // The multiplication rounds down, and the subtrahend (power) rounds up (so the base rounds up too). // Because bI / (bI + aI) <= 1, the exponent rounds down. - uint256 denominator = balanceIn.add(amountIn); - uint256 base = balanceIn.divUp(denominator); + uint256 denominator = virtualBalanceIn.add(amountIn); + uint256 base = virtualBalanceIn.divUp(denominator); uint256 exponent = weightIn.divDown(weightOut); uint256 power = base.powUp(exponent); - return Math.min(factBalance, balanceOut.mulDown(power.complement())); + return Math.min(factBalance, virtualBalanceOut.mulDown(power.complement())); } // Computes how many tokens must be sent to a pool in order to take `amountOut`, given the - // current balances and weights. + // virtual balances and weights. function _calcInGivenOut( - uint256 balanceIn, + uint256 virtualBalanceIn, uint256 weightIn, - uint256 balanceOut, + uint256 virtualBalanceOut, uint256 weightOut, uint256 amountOut ) internal pure returns (uint256) { /********************************************************************************************** // inGivenOut // // aO = amountOut // - // bO = balanceOut // - // bI = balanceIn / / bO \ (wO / wI) \ // + // bO = virtualBalanceOut // + // bI = virtualBalanceIn / / bO \ (wO / wI) \ // // aI = amountIn aI = bI * | | -------------------------- | ^ - 1 | // // wI = weightIn \ \ ( bO - aO ) / / // // wO = weightOut // @@ -82,7 +82,7 @@ library RangeMath { // The multiplication rounds up, and the power rounds up (so the base rounds up too). // Because b0 / (b0 - a0) >= 1, the exponent rounds up. - uint256 base = balanceOut.divUp(balanceOut.sub(amountOut)); + uint256 base = virtualBalanceOut.divUp(virtualBalanceOut.sub(amountOut)); uint256 exponent = weightOut.divUp(weightIn); uint256 power = base.powUp(exponent); @@ -90,7 +90,7 @@ library RangeMath { // the following subtraction should never revert. uint256 ratio = power.sub(FixedPoint.ONE); - return balanceIn.mulUp(ratio); + return virtualBalanceIn.mulUp(ratio); } function _calcBptOutGivenExactTokensIn( diff --git a/pkg/pool-range/test/BaseRangePool.behavior.ts b/pkg/pool-range/test/BaseRangePool.behavior.ts index da272349f..d6f6c1cca 100644 --- a/pkg/pool-range/test/BaseRangePool.behavior.ts +++ b/pkg/pool-range/test/BaseRangePool.behavior.ts @@ -1,6 +1,6 @@ import { ethers } from 'hardhat'; import { expect } from 'chai'; -import { BigNumber } from 'ethers'; +import { BigNumber, Contract } from 'ethers'; import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address'; import { PoolSpecialization, SwapKind } from '@balancer-labs/balancer-js'; import { BigNumberish, bn, fp, fpMul, pct } from '@balancer-labs/v2-helpers/src/numbers'; @@ -13,6 +13,7 @@ import { expectBalanceChange } from '@balancer-labs/v2-helpers/src/test/tokenBal import Vault from '@balancer-labs/v2-helpers/src/models/vault/Vault'; import BaseRangePool from './helpers/BaseRangePool'; import RangePool from './helpers/RangePool'; +import { deploy } from './helpers/contract'; export function itBehavesAsRangePool(numberOfTokens: number): void { const POOL_SWAP_FEE_PERCENTAGE = fp(0.01); @@ -25,6 +26,7 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { let recipient: SignerWithAddress, other: SignerWithAddress, lp: SignerWithAddress; let vault: Vault; let pool: BaseRangePool, allTokens: TokenList, tokens: TokenList; + let math: Contract; const ZEROS = Array(numberOfTokens).fill(bn(0)); const weights: BigNumberish[] = WEIGHTS.slice(0, numberOfTokens); @@ -43,6 +45,7 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { before('setup signers', async () => { [, lp, recipient, other] = await ethers.getSigners(); + math = await deploy('ExternalRangeMath'); }); sharedBeforeEach('deploy tokens and vault', async () => { @@ -555,15 +558,73 @@ export function itBehavesAsRangePool(numberOfTokens: number): void { const maxAmountOut = await pool.getMaxOut(0); await pool.swapGivenOut({ in: 1, out: 0, amount: maxAmountOut, from: lp, recipient }); - expect((await pool.getBalances())[0]).to.be.eq(0); + const prevBalances = await pool.getBalances(); + expect(prevBalances[0]).to.be.eq(0); + expect(prevBalances[1]).to.be.gt(0); - const amountsIn = ZEROS.map((n, i) => (i === 1 ? fp(0.1) : fp(0.2))); + const prevVirtualBalances = await pool.getVirtualBalances(); + + let amountsIn = ZEROS.map((n, i) => (i === 0 ? fp(0) : fp(0.1))); const minimumBptOut = bn(1); + const ratioMin = await math.calcRatioMin(prevBalances, amountsIn); + await pool.joinGivenIn({ amountsIn, minimumBptOut, from: lp }); + + const newBalances = await pool.getBalances(); + expect(newBalances[0]).to.be.eq(0); + expect(newBalances[1]).to.be.eq(prevBalances[1].add(fp(0.1))); + + // newVirtualBalances[i] - prevVirtualBalances[i] == prevVirtualBalances[i] * rationMin + const newVirtualBalances = await pool.getVirtualBalances(); + expect(prevVirtualBalances[0].mul(ratioMin).div(fp(1))).to.be.equalWithError( + newVirtualBalances[0].sub(prevVirtualBalances[0]), + 0.000001 + ); + expect(prevVirtualBalances[1].mul(ratioMin).div(fp(1))).to.be.equalWithError( + newVirtualBalances[1].sub(prevVirtualBalances[1]), + 0.000001 + ); + + // finally, join all tokens + amountsIn = ZEROS.map((n, i) => (i === 1 ? fp(0.1) : fp(0.2))); await pool.joinGivenIn({ amountsIn, minimumBptOut, from: lp }); expect((await pool.getBalances())[0]).to.be.gt(0); }); + it('swap all amount of token#0 and exit', async () => { + expect((await pool.getBalances())[0]).to.be.gt(0); + + const maxAmountOut = await pool.getMaxOut(0); + await pool.swapGivenOut({ in: 1, out: 0, amount: maxAmountOut, from: lp, recipient }); + + expect((await pool.getBalances())[0]).to.be.eq(0); + + const amountsIn = ZEROS.map((n, i) => (i === 0 ? fp(0) : fp(0.1))); + const minimumBptOut = bn(1); + await pool.joinGivenIn({ amountsIn, minimumBptOut, from: lp }); + + const prevVirtualBalances = await pool.getVirtualBalances(); + + const previousBptBalance = await pool.balanceOf(lp); + const totalSupply = await pool.totalSupply(); + const ratioMin = previousBptBalance.mul(fp(1)).div(totalSupply); + await pool.multiExitGivenIn({ from: lp, bptIn: previousBptBalance }); + + const newVirtualBalances = await pool.getVirtualBalances(); + + // prevVirtualBalances[i] - newVirtualBalances[i] == prevVirtualBalances[i] * rationMin + expect(prevVirtualBalances[0].mul(ratioMin).div(fp(1))).to.be.equalWithError( + prevVirtualBalances[0].sub(newVirtualBalances[0]), + 0.000001 + ); + expect(prevVirtualBalances[1].mul(ratioMin).div(fp(1))).to.be.equalWithError( + prevVirtualBalances[1].sub(newVirtualBalances[1]), + 0.000001 + ); + + expect(await pool.balanceOf(lp)).to.equal(0); + }); + it('reverts if token in is not in the pool', async () => { await expect(pool.swapGivenIn({ in: allTokens.GRT, out: 0, amount: 1, from: lp })).to.be.revertedWith( 'TOKEN_NOT_REGISTERED' diff --git a/pkg/pool-range/test/helpers/BaseRangePool.ts b/pkg/pool-range/test/helpers/BaseRangePool.ts index 5872c774f..edbae7371 100644 --- a/pkg/pool-range/test/helpers/BaseRangePool.ts +++ b/pkg/pool-range/test/helpers/BaseRangePool.ts @@ -39,8 +39,8 @@ import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; import BasePool from '@balancer-labs/v2-helpers/src/models/pools/base/BasePool'; import { Account } from '@balancer-labs/v2-helpers/src/models/types/types'; -const MAX_IN_RATIO = fp(0.3); -const MAX_OUT_RATIO = fp(0.3); +//const MAX_IN_RATIO = fp(0.3); +//const MAX_OUT_RATIO = fp(0.3); const MAX_INVARIANT_RATIO = fp(3); const MIN_INVARIANT_RATIO = fp(0.7); From fe24f5d97082c9ec1a890541ca06087f2192d778 Mon Sep 17 00:00:00 2001 From: dcpp Date: Tue, 16 Sep 2025 14:34:15 +0300 Subject: [PATCH 12/13] Remove Vault.flashLoan function --- pkg/balancer-js/src/utils/errors.ts | 3 - pkg/interfaces/README.md | 31 --- .../solidity-utils/helpers/BalancerErrors.sol | 3 - .../IProtocolFeePercentagesProvider.sol | 8 +- .../contracts/vault/IFlashLoanRecipient.sol | 37 --- .../vault/IProtocolFeesCollector.sol | 5 - pkg/interfaces/contracts/vault/IVault.sol | 31 +-- .../ComposableStablePoolProtocolFees.test.ts | 7 - .../external-fees/ProtocolFeeCache.sol | 2 +- pkg/pool-utils/test/ProtocolFeeCache.test.ts | 9 +- .../test/PostJoinExitProtocolFees.test.ts | 7 - .../ProtocolFeePercentagesProvider.sol | 12 +- .../special/DoubleEntrypointFixRelayer.sol | 181 -------------- .../ProtocolFeePercentagesProvider.test.ts | 33 --- pkg/vault/contracts/Fees.sol | 10 - pkg/vault/contracts/FlashLoans.sol | 85 ------- pkg/vault/contracts/ProtocolFeesCollector.sol | 17 -- pkg/vault/contracts/Vault.sol | 4 +- .../contracts/test/MockFlashLoanRecipient.sol | 85 ------- pkg/vault/test/Fees.test.ts | 31 --- pkg/vault/test/FlashLoan.test.ts | 228 ------------------ pvt/helpers/src/models/vault/Vault.ts | 29 +-- pvt/helpers/src/models/vault/types.ts | 1 - 23 files changed, 9 insertions(+), 850 deletions(-) delete mode 100644 pkg/interfaces/contracts/vault/IFlashLoanRecipient.sol delete mode 100644 pkg/standalone-utils/contracts/relayer/special/DoubleEntrypointFixRelayer.sol delete mode 100644 pkg/vault/contracts/FlashLoans.sol delete mode 100644 pkg/vault/contracts/test/MockFlashLoanRecipient.sol delete mode 100644 pkg/vault/test/FlashLoan.test.ts diff --git a/pkg/balancer-js/src/utils/errors.ts b/pkg/balancer-js/src/utils/errors.ts index 3f4315cf5..8d658b008 100644 --- a/pkg/balancer-js/src/utils/errors.ts +++ b/pkg/balancer-js/src/utils/errors.ts @@ -159,10 +159,7 @@ const balancerErrorCodes: Record = { '525': 'NONZERO_TOKEN_BALANCE', '526': 'BALANCE_TOTAL_OVERFLOW', '527': 'POOL_NO_TOKENS', - '528': 'INSUFFICIENT_FLASH_LOAN_BALANCE', '600': 'SWAP_FEE_PERCENTAGE_TOO_HIGH', - '601': 'FLASH_LOAN_FEE_PERCENTAGE_TOO_HIGH', - '602': 'INSUFFICIENT_FLASH_LOAN_FEE_AMOUNT', '603': 'AUM_FEE_PERCENTAGE_TOO_HIGH', '700': 'SPLITTER_FEE_PERCENTAGE_TOO_HIGH', '998': 'UNIMPLEMENTED', diff --git a/pkg/interfaces/README.md b/pkg/interfaces/README.md index a1650f29c..0a896dae7 100644 --- a/pkg/interfaces/README.md +++ b/pkg/interfaces/README.md @@ -93,37 +93,6 @@ contract SimpleDepositor { } ``` -Sample contract that performs Flash Loans: - -```solidity -pragma solidity ^0.7.0; - -import "@balancer-labs/v2-interfaces/contracts/vault/IVault.sol"; -import "@balancer-labs/v2-interfaces/contracts/vault/IFlashLoanRecipient.sol"; - -contract FlashLoanRecipient is IFlashLoanRecipient { - IVault private constant vault = "0xBA12222222228d8Ba445958a75a0704d566BF2C8"; - - function makeFlashLoan( - IERC20[] memory tokens, - uint256[] memory amounts, - bytes memory userData - ) external { - vault.flashLoan(this, tokens, amounts, userData); - } - - function receiveFlashLoan( - IERC20[] memory tokens, - uint256[] memory amounts, - uint256[] memory feeAmounts, - bytes memory userData - ) external override { - require(msg.sender == vault); - ... - } -} -``` - ### Notes In addition to interfaces, it also includes a small number of libraries that encapsulate enum types for particular pools (e.g., [StablePoolUserData](contracts/pool-stable/StablePoolUserData.sol), and functions for working with encoding and decoding `userData`. (See the `balancer-js` package for TypeScript versions of these utilities.) diff --git a/pkg/interfaces/contracts/solidity-utils/helpers/BalancerErrors.sol b/pkg/interfaces/contracts/solidity-utils/helpers/BalancerErrors.sol index fa2df50ce..db5e86db8 100644 --- a/pkg/interfaces/contracts/solidity-utils/helpers/BalancerErrors.sol +++ b/pkg/interfaces/contracts/solidity-utils/helpers/BalancerErrors.sol @@ -276,12 +276,9 @@ library Errors { uint256 internal constant NONZERO_TOKEN_BALANCE = 525; uint256 internal constant BALANCE_TOTAL_OVERFLOW = 526; uint256 internal constant POOL_NO_TOKENS = 527; - uint256 internal constant INSUFFICIENT_FLASH_LOAN_BALANCE = 528; // Fees uint256 internal constant SWAP_FEE_PERCENTAGE_TOO_HIGH = 600; - uint256 internal constant FLASH_LOAN_FEE_PERCENTAGE_TOO_HIGH = 601; - uint256 internal constant INSUFFICIENT_FLASH_LOAN_FEE_AMOUNT = 602; uint256 internal constant AUM_FEE_PERCENTAGE_TOO_HIGH = 603; // FeeSplitter diff --git a/pkg/interfaces/contracts/standalone-utils/IProtocolFeePercentagesProvider.sol b/pkg/interfaces/contracts/standalone-utils/IProtocolFeePercentagesProvider.sol index c8cd432d4..d8c603c44 100644 --- a/pkg/interfaces/contracts/standalone-utils/IProtocolFeePercentagesProvider.sol +++ b/pkg/interfaces/contracts/standalone-utils/IProtocolFeePercentagesProvider.sol @@ -27,7 +27,7 @@ interface IProtocolFeePercentagesProvider { event ProtocolFeeTypeRegistered(uint256 indexed feeType, string name, uint256 maximumPercentage); // Emitted when the value of a fee type changes. - // IMPORTANT: it is possible for a third party to modify the SWAP and FLASH_LOAN fee type values directly in the + // IMPORTANT: it is possible for a third party to modify the SWAP fee type values directly in the // ProtocolFeesCollector, which will result in this event not being emitted despite their value changing. Such usage // of the ProtocolFeesCollector is however discouraged: all state-changing interactions with it should originate in // this contract. @@ -61,11 +61,10 @@ interface IProtocolFeePercentagesProvider { /** * @dev Sets the percentage value for `feeType` to `newValue`. * - * IMPORTANT: it is possible for a third party to modify the SWAP and FLASH_LOAN fee type values directly in the + * IMPORTANT: it is possible for a third party to modify the SWAP fee type values directly in the * ProtocolFeesCollector, without invoking this function. This will result in the `ProtocolFeePercentageChanged` * event not being emitted despite their value changing. Such usage of the ProtocolFeesCollector is however - * discouraged: only this contract should be granted permission to call `setSwapFeePercentage` and - * `setFlashLoanFeePercentage`. + * discouraged: only this contract should be granted permission to call `setSwapFeePercentage`. */ function setFeeTypePercentage(uint256 feeType, uint256 newValue) external; @@ -93,7 +92,6 @@ library ProtocolFeeType { // solhint-disable private-vars-leading-underscore uint256 internal constant SWAP = 0; - uint256 internal constant FLASH_LOAN = 1; uint256 internal constant YIELD = 2; uint256 internal constant AUM = 3; // solhint-enable private-vars-leading-underscore diff --git a/pkg/interfaces/contracts/vault/IFlashLoanRecipient.sol b/pkg/interfaces/contracts/vault/IFlashLoanRecipient.sol deleted file mode 100644 index 10ad894c1..000000000 --- a/pkg/interfaces/contracts/vault/IFlashLoanRecipient.sol +++ /dev/null @@ -1,37 +0,0 @@ -// 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 . - -pragma solidity >=0.7.0 <0.9.0; - -// Inspired by Aave Protocol's IFlashLoanReceiver. - -import "../solidity-utils/openzeppelin/IERC20.sol"; - -interface IFlashLoanRecipient { - /** - * @dev When `flashLoan` is called on the Vault, it invokes the `receiveFlashLoan` hook on the recipient. - * - * At the time of the call, the Vault will have transferred `amounts` for `tokens` to the recipient. Before this - * call returns, the recipient must have transferred `amounts` plus `feeAmounts` for each token back to the - * Vault, or else the entire flash loan will revert. - * - * `userData` is the same value passed in the `IVault.flashLoan` call. - */ - function receiveFlashLoan( - IERC20[] memory tokens, - uint256[] memory amounts, - uint256[] memory feeAmounts, - bytes memory userData - ) external; -} diff --git a/pkg/interfaces/contracts/vault/IProtocolFeesCollector.sol b/pkg/interfaces/contracts/vault/IProtocolFeesCollector.sol index 7fb4482f7..a5986bcdf 100644 --- a/pkg/interfaces/contracts/vault/IProtocolFeesCollector.sol +++ b/pkg/interfaces/contracts/vault/IProtocolFeesCollector.sol @@ -22,7 +22,6 @@ import "./IAuthorizer.sol"; interface IProtocolFeesCollector { event SwapFeePercentageChanged(uint256 newSwapFeePercentage); - event FlashLoanFeePercentageChanged(uint256 newFlashLoanFeePercentage); function withdrawCollectedFees( IERC20[] calldata tokens, @@ -32,12 +31,8 @@ interface IProtocolFeesCollector { function setSwapFeePercentage(uint256 newSwapFeePercentage) external; - function setFlashLoanFeePercentage(uint256 newFlashLoanFeePercentage) external; - function getSwapFeePercentage() external view returns (uint256); - function getFlashLoanFeePercentage() external view returns (uint256); - function getCollectedFeeAmounts(IERC20[] memory tokens) external view returns (uint256[] memory feeAmounts); function getAuthorizer() external view returns (IAuthorizer); diff --git a/pkg/interfaces/contracts/vault/IVault.sol b/pkg/interfaces/contracts/vault/IVault.sol index c55f9d25c..30fc0ac3c 100644 --- a/pkg/interfaces/contracts/vault/IVault.sol +++ b/pkg/interfaces/contracts/vault/IVault.sol @@ -22,7 +22,6 @@ import "../solidity-utils/misc/IWETH.sol"; import "./IAsset.sol"; import "./IAuthorizer.sol"; -import "./IFlashLoanRecipient.sol"; import "./IProtocolFeesCollector.sol"; pragma solidity >=0.7.0 <0.9.0; @@ -649,32 +648,6 @@ interface IVault is ISignaturesValidator, ITemporarilyPausable, IAuthentication FundManagement memory funds ) external returns (int256[] memory assetDeltas); - // Flash Loans - - /** - * @dev Performs a 'flash loan', sending tokens to `recipient`, executing the `receiveFlashLoan` hook on it, - * and then reverting unless the tokens plus a proportional protocol fee have been returned. - * - * The `tokens` and `amounts` arrays must have the same length, and each entry in these indicates the loan amount - * for each token contract. `tokens` must be sorted in ascending order. - * - * The 'userData' field is ignored by the Vault, and forwarded as-is to `recipient` as part of the - * `receiveFlashLoan` call. - * - * Emits `FlashLoan` events. - */ - function flashLoan( - IFlashLoanRecipient recipient, - IERC20[] memory tokens, - uint256[] memory amounts, - bytes memory userData - ) external; - - /** - * @dev Emitted for each individual flash loan performed by `flashLoan`. - */ - event FlashLoan(IFlashLoanRecipient indexed recipient, IERC20 indexed token, uint256 amount, uint256 feeAmount); - // Asset Management // // Each token registered for a Pool can be assigned an Asset Manager, which is able to freely withdraw the Pool's @@ -732,9 +705,7 @@ interface IVault is ISignaturesValidator, ITemporarilyPausable, IAuthentication // Some operations cause the Vault to collect tokens in the form of protocol fees, which can then be withdrawn by // permissioned accounts. // - // There are two kinds of protocol fees: - // - // - flash loan fees: charged on all flash loans, as a percentage of the amounts lent. + // There is one kind of protocol fees: // // - swap fees: a percentage of the fees charged by Pools when performing swaps. For a number of reasons, including // swap gas costs and interface simplicity, protocol swap fees are not charged on each individual swap. Rather, diff --git a/pkg/pool-stable/test/ComposableStablePoolProtocolFees.test.ts b/pkg/pool-stable/test/ComposableStablePoolProtocolFees.test.ts index 6cc173203..14c7bd0d0 100644 --- a/pkg/pool-stable/test/ComposableStablePoolProtocolFees.test.ts +++ b/pkg/pool-stable/test/ComposableStablePoolProtocolFees.test.ts @@ -65,13 +65,6 @@ describe('ComposableStablePoolProtocolFees', () => { await vault.authorizer .connect(admin) .grantPermission(actionId(feesCollector, 'setSwapFeePercentage'), feesProvider.address, feesCollector.address); - await vault.authorizer - .connect(admin) - .grantPermission( - actionId(feesCollector, 'setFlashLoanFeePercentage'), - feesProvider.address, - feesCollector.address - ); }); context('for a 2 token pool', () => { diff --git a/pkg/pool-utils/contracts/external-fees/ProtocolFeeCache.sol b/pkg/pool-utils/contracts/external-fees/ProtocolFeeCache.sol index 351dd9594..8e771201e 100644 --- a/pkg/pool-utils/contracts/external-fees/ProtocolFeeCache.sol +++ b/pkg/pool-utils/contracts/external-fees/ProtocolFeeCache.sol @@ -67,7 +67,7 @@ abstract contract ProtocolFeeCache is IProtocolFeeCache, RecoveryMode { * This is because some Pools may have different protocol fee values for the same type of underlying operation: * for example, Stable Pools might have a different swap protocol fee than Weighted Pools. * This module does not check at all that the chosen fee types have any sort of relation with the operation they're - * assigned to: it is possible to e.g. set a Pool's swap protocol fee to equal the flash loan protocol fee. + * assigned to: it is possible to e.g. set a Pool's swap protocol fee. */ struct ProviderFeeIDs { uint256 swap; diff --git a/pkg/pool-utils/test/ProtocolFeeCache.test.ts b/pkg/pool-utils/test/ProtocolFeeCache.test.ts index 78c246dde..fee73c363 100644 --- a/pkg/pool-utils/test/ProtocolFeeCache.test.ts +++ b/pkg/pool-utils/test/ProtocolFeeCache.test.ts @@ -43,13 +43,6 @@ describe('ProtocolFeeCache', () => { vault.protocolFeesProvider.address ); - await vault.authorizer - .connect(admin) - .grantPermission( - actionId(feesCollector, 'setFlashLoanFeePercentage'), - vault.protocolFeesProvider.address, - feesCollector.address - ); await vault.authorizer .connect(admin) .grantPermission( @@ -79,7 +72,7 @@ describe('ProtocolFeeCache', () => { }); context('using custom fee types', () => { - itTestsProtocolFeePercentages({ swap: ProtocolFee.FLASH_LOAN, yield: ProtocolFee.SWAP, aum: ProtocolFee.YIELD }); + itTestsProtocolFeePercentages({ swap: ProtocolFee.SWAP, yield: ProtocolFee.SWAP, aum: ProtocolFee.YIELD }); }); }); diff --git a/pkg/pool-weighted/test/PostJoinExitProtocolFees.test.ts b/pkg/pool-weighted/test/PostJoinExitProtocolFees.test.ts index 3561d0e00..198e9e196 100644 --- a/pkg/pool-weighted/test/PostJoinExitProtocolFees.test.ts +++ b/pkg/pool-weighted/test/PostJoinExitProtocolFees.test.ts @@ -50,13 +50,6 @@ describe('PostJoinExitProtocolFees', () => { .connect(admin) .grantPermission(actionId(feesProvider, 'setFeeTypePercentage'), admin.address, feesProvider.address); - await vault.authorizer - .connect(admin) - .grantPermission( - actionId(feesCollector, 'setFlashLoanFeePercentage'), - feesProvider.address, - feesCollector.address - ); await vault.authorizer .connect(admin) .grantPermission(actionId(feesCollector, 'setSwapFeePercentage'), feesProvider.address, feesCollector.address); diff --git a/pkg/standalone-utils/contracts/ProtocolFeePercentagesProvider.sol b/pkg/standalone-utils/contracts/ProtocolFeePercentagesProvider.sol index c1a1599fc..082e2b7a3 100644 --- a/pkg/standalone-utils/contracts/ProtocolFeePercentagesProvider.sol +++ b/pkg/standalone-utils/contracts/ProtocolFeePercentagesProvider.sol @@ -42,7 +42,6 @@ contract ProtocolFeePercentagesProvider is IProtocolFeePercentagesProvider, Sing // These are copied from ProtocolFeesCollector uint256 private constant _MAX_PROTOCOL_SWAP_FEE_PERCENTAGE = 50e16; // 50% - uint256 private constant _MAX_PROTOCOL_FLASH_LOAN_FEE_PERCENTAGE = 1e16; // 1% constructor( IVault vault, @@ -58,14 +57,11 @@ contract ProtocolFeePercentagesProvider is IProtocolFeePercentagesProvider, Sing _registerFeeType(ProtocolFeeType.YIELD, "Yield", maxYieldValue, 0); _registerFeeType(ProtocolFeeType.AUM, "Assets Under Management", maxAUMValue, 0); - // Swap and Flash loan types are special as their storage is actually located in the ProtocolFeesCollector. We + // Swap type are special as their storage is actually located in the ProtocolFeesCollector. We // therefore simply mark them as registered, but ignore maximum and initial values. Not calling _registerFeeType // also means that ProtocolFeeTypeRegistered nor ProtocolFeePercentageChanged events will be emitted for these. _feeTypeData[ProtocolFeeType.SWAP].registered = true; _feeTypeData[ProtocolFeeType.SWAP].name = "Swap"; - - _feeTypeData[ProtocolFeeType.FLASH_LOAN].registered = true; - _feeTypeData[ProtocolFeeType.FLASH_LOAN].name = "Flash Loan"; } modifier withValidFeeType(uint256 feeType) { @@ -127,8 +123,6 @@ contract ProtocolFeePercentagesProvider is IProtocolFeePercentagesProvider, Sing if (feeType == ProtocolFeeType.SWAP) { _protocolFeesCollector.setSwapFeePercentage(newValue); - } else if (feeType == ProtocolFeeType.FLASH_LOAN) { - _protocolFeesCollector.setFlashLoanFeePercentage(newValue); } else { _feeTypeData[feeType].value = newValue.toUint64(); } @@ -139,8 +133,6 @@ contract ProtocolFeePercentagesProvider is IProtocolFeePercentagesProvider, Sing function getFeeTypePercentage(uint256 feeType) external view override withValidFeeType(feeType) returns (uint256) { if (feeType == ProtocolFeeType.SWAP) { return _protocolFeesCollector.getSwapFeePercentage(); - } else if (feeType == ProtocolFeeType.FLASH_LOAN) { - return _protocolFeesCollector.getFlashLoanFeePercentage(); } else { return _feeTypeData[feeType].value; } @@ -155,8 +147,6 @@ contract ProtocolFeePercentagesProvider is IProtocolFeePercentagesProvider, Sing { if (feeType == ProtocolFeeType.SWAP) { return _MAX_PROTOCOL_SWAP_FEE_PERCENTAGE; - } else if (feeType == ProtocolFeeType.FLASH_LOAN) { - return _MAX_PROTOCOL_FLASH_LOAN_FEE_PERCENTAGE; } else { return _feeTypeData[feeType].maximum; } diff --git a/pkg/standalone-utils/contracts/relayer/special/DoubleEntrypointFixRelayer.sol b/pkg/standalone-utils/contracts/relayer/special/DoubleEntrypointFixRelayer.sol deleted file mode 100644 index 62a79254d..000000000 --- a/pkg/standalone-utils/contracts/relayer/special/DoubleEntrypointFixRelayer.sol +++ /dev/null @@ -1,181 +0,0 @@ -// 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 . - -pragma solidity ^0.7.0; -pragma experimental ABIEncoderV2; - -import "@balancer-labs/v2-interfaces/contracts/pool-stable/StablePoolUserData.sol"; -import "@balancer-labs/v2-interfaces/contracts/pool-weighted/WeightedPoolUserData.sol"; -import "@balancer-labs/v2-interfaces/contracts/vault/IVault.sol"; -import "@balancer-labs/v2-interfaces/contracts/vault/IFlashLoanRecipient.sol"; - -import "@balancer-labs/v2-solidity-utils/contracts/helpers/ERC20Helpers.sol"; -import "@balancer-labs/v2-solidity-utils/contracts/openzeppelin/SafeERC20.sol"; - -/** - * @title DoubleEntrypointFixRelayer - * @notice This contract performs mitigations to safeguard funds affected by double-entrypoint tokens (mostly Synthetix - * tokens). It doesn't use the standard relayer architecture to simplify the code. - */ -contract DoubleEntrypointFixRelayer is IFlashLoanRecipient { - using SafeERC20 for IERC20; - - // solhint-disable const-name-snakecase - IERC20 public constant BTC_STABLE_POOL_ADDRESS = IERC20(0xFeadd389a5c427952D8fdb8057D6C8ba1156cC56); - bytes32 public constant BTC_STABLE_POOL_ID = 0xfeadd389a5c427952d8fdb8057d6c8ba1156cc56000000000000000000000066; - - // solhint-disable const-name-snakecase - IERC20 public constant wBTC = IERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599); - IERC20 public constant renBTC = IERC20(0xEB4C2781e4ebA804CE9a9803C67d0893436bB27D); - IERC20 public constant sBTC = IERC20(0xfE18be6b3Bd88A2D2A7f928d00292E7a9963CfC6); - IERC20 public constant sBTC_IMPLEMENTATION = IERC20(0x18FcC34bdEaaF9E3b69D2500343527c0c995b1d6); - - IERC20 public constant SNX_WEIGHTED_POOL_ADDRESS = IERC20(0x072f14B85ADd63488DDaD88f855Fda4A99d6aC9B); - bytes32 public constant SNX_WEIGHTED_POOL_ID = 0x072f14b85add63488ddad88f855fda4a99d6ac9b000200000000000000000027; - IERC20 public constant SNX = IERC20(0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F); - IERC20 public constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); - IERC20 public constant SNX_IMPLEMENTATION = IERC20(0x639032d3900875a4cf4960aD6b9ee441657aA93C); - // solhint-enable const-name-snakecase - - // This was removed from the StablePoolEncoder along with StablePool. - uint256 private constant _STABLE_POOL_EXIT_KIND_EXACT_BPT_IN_FOR_TOKENS_OUT = 1; - - IVault private immutable _vault; - IProtocolFeesCollector private immutable _protocolFeeCollector; - - constructor(IVault vault) { - _vault = vault; - _protocolFeeCollector = vault.getProtocolFeesCollector(); - } - - function getVault() public view returns (IVault) { - return _vault; - } - - /** - * @notice Fully exit the BTC Stable Pool into its three components (wBTC, renBTC and sBTC), with no price impact - * nor swap fees. This relayer must have been previously approved by the caller, and proper permissions granted by - * Balancer Governance. - */ - function exitBTCStablePool() external { - IERC20[] memory tokens = new IERC20[](3); - tokens[0] = wBTC; - tokens[1] = renBTC; - tokens[2] = sBTC; - uint256 bptAmountIn = BTC_STABLE_POOL_ADDRESS.balanceOf(msg.sender); - - // Pull sBTC out from the Protocol Fee Collector and send it to the Vault ready for the exit. Computing the - // exact amount of sBTC required is a complicated task, as it involves due protocol fees, so we simply send all - // of it. - _withdrawFromProtocolFeeCollector(sBTC, sBTC.balanceOf(address(_protocolFeeCollector))); - - // Perform the exit. - bytes memory userData = abi.encode(_STABLE_POOL_EXIT_KIND_EXACT_BPT_IN_FOR_TOKENS_OUT, bptAmountIn); - IVault.ExitPoolRequest memory request = IVault.ExitPoolRequest( - _asIAsset(tokens), - new uint256[](tokens.length), - userData, - false - ); - getVault().exitPool(BTC_STABLE_POOL_ID, msg.sender, msg.sender, request); - - // Sweep any remaining sBTC back into the Protocol Fee Collector. - IERC20[] memory sBTCEntrypoints = new IERC20[](2); - sBTCEntrypoints[0] = sBTC_IMPLEMENTATION; - sBTCEntrypoints[1] = IERC20(address(sBTC)); - sweepDoubleEntrypointToken(sBTCEntrypoints); - } - - /** - * @notice Fully exit the SNX Weighted Pool into its two components (SNX and WETH), with no price impact nor swap - * fees. This relayer must have been previously approved by the caller, and proper permissions granted by - * Balancer Governance. - */ - function exitSNXWeightedPool() external { - IERC20[] memory tokens = new IERC20[](2); - tokens[0] = SNX; - tokens[1] = WETH; - uint256 bptAmountIn = SNX_WEIGHTED_POOL_ADDRESS.balanceOf(msg.sender); - - // Pull SNX out from the Protocol Fee Collector and send it to the Vault ready for the exit. Computing the - // exact amount of SNX required is a complicated task, as it involves due protocol fees, so we simply send all - // of it. - _withdrawFromProtocolFeeCollector(SNX, SNX.balanceOf(address(_protocolFeeCollector))); - - // Perform the exit. - bytes memory userData = abi.encode(WeightedPoolUserData.ExitKind.EXACT_BPT_IN_FOR_TOKENS_OUT, bptAmountIn); - IVault.ExitPoolRequest memory request = IVault.ExitPoolRequest( - _asIAsset(tokens), - new uint256[](tokens.length), - userData, - false - ); - getVault().exitPool(SNX_WEIGHTED_POOL_ID, msg.sender, msg.sender, request); - - // Sweep any remaining SNX back into the Protocol Fee Collector. - IERC20[] memory snxEntrypoints = new IERC20[](2); - snxEntrypoints[0] = SNX_IMPLEMENTATION; - snxEntrypoints[1] = IERC20(address(SNX)); - sweepDoubleEntrypointToken(snxEntrypoints); - } - - function _withdrawFromProtocolFeeCollector(IERC20 token, uint256 amount) internal { - IERC20[] memory tokens = new IERC20[](1); - tokens[0] = token; - uint256[] memory amounts = new uint256[](1); - amounts[0] = amount; - - _protocolFeeCollector.withdrawCollectedFees(tokens, amounts, address(_vault)); - } - - /** - * @notice Sweep all SNX and sBTC from the Vault into the Protocol Fee Collector. - */ - function sweepSNXsBTC() public { - IERC20[] memory snxEntrypoints = new IERC20[](2); - snxEntrypoints[0] = SNX_IMPLEMENTATION; - snxEntrypoints[1] = IERC20(address(SNX)); - - sweepDoubleEntrypointToken(snxEntrypoints); - - IERC20[] memory sBTCEntrypoints = new IERC20[](2); - sBTCEntrypoints[0] = sBTC_IMPLEMENTATION; - sBTCEntrypoints[1] = IERC20(address(sBTC)); - sweepDoubleEntrypointToken(sBTCEntrypoints); - } - - /** - * @notice Sweep a double-entrypoint token into the Protocol Fee Collector by passing all entrypoints of a given - * token. - */ - function sweepDoubleEntrypointToken(IERC20[] memory tokens) public { - uint256[] memory amounts = new uint256[](tokens.length); - amounts[0] = tokens[0].balanceOf(address(_vault)); - _vault.flashLoan(this, tokens, amounts, "0x"); - } - - /** - * @dev Flash loan callback. Assumes that it receives a flashloan of multiple assets (all entrypoints of a Synthetix - * synth). We only need to repay the first loan as that will automatically all other loans. - */ - function receiveFlashLoan( - IERC20[] memory tokens, - uint256[] memory amounts, - uint256[] memory, - bytes memory - ) external override { - _require(msg.sender == address(_vault), Errors.CALLER_NOT_VAULT); - tokens[0].safeTransfer(address(_vault), amounts[0]); - } -} diff --git a/pkg/standalone-utils/test/ProtocolFeePercentagesProvider.test.ts b/pkg/standalone-utils/test/ProtocolFeePercentagesProvider.test.ts index aca2f07f0..ecb0f04fb 100644 --- a/pkg/standalone-utils/test/ProtocolFeePercentagesProvider.test.ts +++ b/pkg/standalone-utils/test/ProtocolFeePercentagesProvider.test.ts @@ -17,7 +17,6 @@ describe('ProtocolFeePercentagesProvider', function () { enum FeeType { Swap = 0, - FlashLoan = 1, Yield = 2, AUM = 3, } @@ -26,7 +25,6 @@ describe('ProtocolFeePercentagesProvider', function () { // Note that these two values are not passed - they're hardcoded into the ProtocolFeesCollector const MAX_SWAP_VALUE = fp(0.5); - const MAX_FLASH_LOAN_VALUE = fp(0.01); const MAX_AUM_VALUE = fp(0.2); const MAX_YIELD_VALUE = fp(0.8); @@ -128,8 +126,6 @@ describe('ProtocolFeePercentagesProvider', function () { context('native fee types', () => { itReturnsNameAndMaximum(FeeType.Swap, 'Swap', MAX_SWAP_VALUE); - - itReturnsNameAndMaximum(FeeType.FlashLoan, 'Flash Loan', MAX_FLASH_LOAN_VALUE); }); context('custom fee types', () => { @@ -175,8 +171,6 @@ describe('ProtocolFeePercentagesProvider', function () { context('native fee types', () => { itValidatesFeePercentagesCorrectly(FeeType.Swap, MAX_SWAP_VALUE); - - itValidatesFeePercentagesCorrectly(FeeType.FlashLoan, MAX_FLASH_LOAN_VALUE); }); context('custom fee types', () => { @@ -217,13 +211,6 @@ describe('ProtocolFeePercentagesProvider', function () { provider.address, feesCollector.address ); - await authorizer - .connect(admin) - .grantPermission( - actionId(feesCollector, 'setFlashLoanFeePercentage'), - provider.address, - feesCollector.address - ); }); function itSetsTheValueCorrectly(feeType: number, value: BigNumber) { @@ -280,8 +267,6 @@ describe('ProtocolFeePercentagesProvider', function () { } itSetsNativeFeeTypeValueCorrectly(FeeType.Swap, MAX_SWAP_VALUE); - - itSetsNativeFeeTypeValueCorrectly(FeeType.FlashLoan, MAX_FLASH_LOAN_VALUE); }); context('custom fee types', () => { @@ -354,9 +339,6 @@ describe('ProtocolFeePercentagesProvider', function () { await authorizer .connect(admin) .grantPermission(actionId(feesCollector, 'setSwapFeePercentage'), other.address, feesCollector.address); - await authorizer - .connect(admin) - .grantPermission(actionId(feesCollector, 'setFlashLoanFeePercentage'), other.address, feesCollector.address); }); describe('swap fee', () => { @@ -365,13 +347,6 @@ describe('ProtocolFeePercentagesProvider', function () { expect(await provider.getFeeTypePercentage(FeeType.Swap)).to.equal(fp(0.13)); }); }); - - describe('flash loan fee', () => { - it('the provider tracks value changes', async () => { - await feesCollector.connect(other).setFlashLoanFeePercentage(fp(0.0013)); - expect(await provider.getFeeTypePercentage(FeeType.FlashLoan)).to.equal(fp(0.0013)); - }); - }); }); describe('register fee type', () => { @@ -385,14 +360,6 @@ describe('ProtocolFeePercentagesProvider', function () { .grantPermission(actionId(provider, 'registerFeeType'), authorized.address, provider.address); }); - context('when the fee type is already in use', () => { - it('reverts', async () => { - await expect(provider.connect(authorized).registerFeeType(FeeType.FlashLoan, '', 0, 0)).to.be.revertedWith( - 'Fee type already registered' - ); - }); - }); - context('when the maximum value is 0%', () => { it('reverts', async () => { await expect(provider.connect(authorized).registerFeeType(NEW_FEE_TYPE, '', 0, 0)).to.be.revertedWith( diff --git a/pkg/vault/contracts/Fees.sol b/pkg/vault/contracts/Fees.sol index 737108e9d..6f8aed1ef 100644 --- a/pkg/vault/contracts/Fees.sol +++ b/pkg/vault/contracts/Fees.sol @@ -50,16 +50,6 @@ abstract contract Fees is IVault { return getProtocolFeesCollector().getSwapFeePercentage(); } - /** - * @dev Returns the protocol fee amount to charge for a flash loan of `amount`. - */ - function _calculateFlashLoanFeeAmount(uint256 amount) internal view returns (uint256) { - // Fixed point multiplication introduces error: we round up, which means in certain scenarios the charged - // percentage can be slightly higher than intended. - uint256 percentage = getProtocolFeesCollector().getFlashLoanFeePercentage(); - return FixedPoint.mulUp(amount, percentage); - } - function _payFeeAmount(IERC20 token, uint256 amount) internal { if (amount > 0) { token.safeTransfer(address(getProtocolFeesCollector()), amount); diff --git a/pkg/vault/contracts/FlashLoans.sol b/pkg/vault/contracts/FlashLoans.sol deleted file mode 100644 index 15d1f9c18..000000000 --- a/pkg/vault/contracts/FlashLoans.sol +++ /dev/null @@ -1,85 +0,0 @@ -// 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 . - -// This flash loan provider was based on the Aave protocol's open source -// implementation and terminology and interfaces are intentionally kept -// similar - -pragma solidity ^0.7.0; -pragma experimental ABIEncoderV2; - -import "@balancer-labs/v2-interfaces/contracts/solidity-utils/helpers/BalancerErrors.sol"; -import "@balancer-labs/v2-interfaces/contracts/solidity-utils/openzeppelin/IERC20.sol"; -import "@balancer-labs/v2-interfaces/contracts/vault/IFlashLoanRecipient.sol"; - -import "@balancer-labs/v2-solidity-utils/contracts/openzeppelin/ReentrancyGuard.sol"; -import "@balancer-labs/v2-solidity-utils/contracts/openzeppelin/SafeERC20.sol"; - -import "./Fees.sol"; - -/** - * @dev Handles Flash Loans through the Vault. Calls the `receiveFlashLoan` hook on the flash loan recipient - * contract, which implements the `IFlashLoanRecipient` interface. - */ -abstract contract FlashLoans is Fees, ReentrancyGuard, TemporarilyPausable { - using SafeERC20 for IERC20; - - function flashLoan( - IFlashLoanRecipient recipient, - IERC20[] memory tokens, - uint256[] memory amounts, - bytes memory userData - ) external override nonReentrant whenNotPaused { - InputHelpers.ensureInputLengthMatch(tokens.length, amounts.length); - - uint256[] memory feeAmounts = new uint256[](tokens.length); - uint256[] memory preLoanBalances = new uint256[](tokens.length); - - // Used to ensure `tokens` is sorted in ascending order, which ensures token uniqueness. - IERC20 previousToken = IERC20(0); - - for (uint256 i = 0; i < tokens.length; ++i) { - IERC20 token = tokens[i]; - uint256 amount = amounts[i]; - - _require(token > previousToken, token == IERC20(0) ? Errors.ZERO_TOKEN : Errors.UNSORTED_TOKENS); - previousToken = token; - - preLoanBalances[i] = token.balanceOf(address(this)); - feeAmounts[i] = _calculateFlashLoanFeeAmount(amount); - - _require(preLoanBalances[i] >= amount, Errors.INSUFFICIENT_FLASH_LOAN_BALANCE); - token.safeTransfer(address(recipient), amount); - } - - recipient.receiveFlashLoan(tokens, amounts, feeAmounts, userData); - - for (uint256 i = 0; i < tokens.length; ++i) { - IERC20 token = tokens[i]; - uint256 preLoanBalance = preLoanBalances[i]; - - // Checking for loan repayment first (without accounting for fees) makes for simpler debugging, and results - // in more accurate revert reasons if the flash loan protocol fee percentage is zero. - uint256 postLoanBalance = token.balanceOf(address(this)); - _require(postLoanBalance >= preLoanBalance, Errors.INVALID_POST_LOAN_BALANCE); - - // No need for checked arithmetic since we know the loan was fully repaid. - uint256 receivedFeeAmount = postLoanBalance - preLoanBalance; - _require(receivedFeeAmount >= feeAmounts[i], Errors.INSUFFICIENT_FLASH_LOAN_FEE_AMOUNT); - - _payFeeAmount(token, receivedFeeAmount); - emit FlashLoan(recipient, token, amounts[i], receivedFeeAmount); - } - } -} diff --git a/pkg/vault/contracts/ProtocolFeesCollector.sol b/pkg/vault/contracts/ProtocolFeesCollector.sol index 5725890d3..29fb90a5c 100644 --- a/pkg/vault/contracts/ProtocolFeesCollector.sol +++ b/pkg/vault/contracts/ProtocolFeesCollector.sol @@ -35,7 +35,6 @@ contract ProtocolFeesCollector is IProtocolFeesCollector, Authentication, Reentr // Absolute maximum fee percentages (1e18 = 100%, 1e16 = 1%). uint256 private constant _MAX_PROTOCOL_SWAP_FEE_PERCENTAGE = 50e16; // 50% - uint256 private constant _MAX_PROTOCOL_FLASH_LOAN_FEE_PERCENTAGE = 1e16; // 1% IVault public immutable override vault; @@ -46,9 +45,6 @@ contract ProtocolFeesCollector is IProtocolFeesCollector, Authentication, Reentr // when users join and exit them. uint256 private _swapFeePercentage; - // The flash loan fee is charged whenever a flash loan occurs, as a percentage of the tokens lent. - uint256 private _flashLoanFeePercentage; - constructor(IVault _vault) // The ProtocolFeesCollector is a singleton, so it simply uses its own address to disambiguate action // identifiers. @@ -77,23 +73,10 @@ contract ProtocolFeesCollector is IProtocolFeesCollector, Authentication, Reentr emit SwapFeePercentageChanged(newSwapFeePercentage); } - function setFlashLoanFeePercentage(uint256 newFlashLoanFeePercentage) external override authenticate { - _require( - newFlashLoanFeePercentage <= _MAX_PROTOCOL_FLASH_LOAN_FEE_PERCENTAGE, - Errors.FLASH_LOAN_FEE_PERCENTAGE_TOO_HIGH - ); - _flashLoanFeePercentage = newFlashLoanFeePercentage; - emit FlashLoanFeePercentageChanged(newFlashLoanFeePercentage); - } - function getSwapFeePercentage() external view override returns (uint256) { return _swapFeePercentage; } - function getFlashLoanFeePercentage() external view override returns (uint256) { - return _flashLoanFeePercentage; - } - function getCollectedFeeAmounts(IERC20[] memory tokens) external view diff --git a/pkg/vault/contracts/Vault.sol b/pkg/vault/contracts/Vault.sol index 182e7b4f2..3b0cee7b9 100644 --- a/pkg/vault/contracts/Vault.sol +++ b/pkg/vault/contracts/Vault.sol @@ -19,7 +19,6 @@ import "@balancer-labs/v2-interfaces/contracts/solidity-utils/misc/IWETH.sol"; import "@balancer-labs/v2-interfaces/contracts/vault/IAuthorizer.sol"; import "./VaultAuthorization.sol"; -import "./FlashLoans.sol"; import "./Swaps.sol"; /** @@ -35,7 +34,6 @@ import "./Swaps.sol"; * * - `AssetManagers`: Pool token Asset Manager registry, and Asset Manager interactions. * - `Fees`: set and compute protocol fees. - * - `FlashLoans`: flash loan transfers and fees. * - `PoolBalances`: Pool joins and exits. * - `PoolRegistry`: Pool registration, ID management, and basic queries. * - `PoolTokens`: Pool token registration and registration, and balance queries. @@ -57,7 +55,7 @@ import "./Swaps.sol"; * utilization of `internal` functions (particularly inside modifiers), usage of named return arguments, dedicated * storage access methods, dynamic revert reason generation, and usage of inline assembly, to name a few. */ -contract Vault is VaultAuthorization, FlashLoans, Swaps { +contract Vault is VaultAuthorization, Swaps { constructor( IAuthorizer authorizer, IWETH weth, diff --git a/pkg/vault/contracts/test/MockFlashLoanRecipient.sol b/pkg/vault/contracts/test/MockFlashLoanRecipient.sol deleted file mode 100644 index fb31548c7..000000000 --- a/pkg/vault/contracts/test/MockFlashLoanRecipient.sol +++ /dev/null @@ -1,85 +0,0 @@ -// 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 . - -pragma solidity ^0.7.0; - -import "@balancer-labs/v2-interfaces/contracts/solidity-utils/openzeppelin/IERC20.sol"; -import "@balancer-labs/v2-interfaces/contracts/vault/IFlashLoanRecipient.sol"; -import "@balancer-labs/v2-interfaces/contracts/vault/IVault.sol"; - -import "@balancer-labs/v2-solidity-utils/contracts/openzeppelin/SafeERC20.sol"; -import "@balancer-labs/v2-solidity-utils/contracts/test/TestToken.sol"; -import "@balancer-labs/v2-solidity-utils/contracts/math/Math.sol"; - -contract MockFlashLoanRecipient is IFlashLoanRecipient { - using Math for uint256; - using SafeERC20 for IERC20; - - address public immutable vault; - bool public repayLoan; - bool public repayInExcess; - bool public reenter; - - constructor(address _vault) { - vault = _vault; - repayLoan = true; - repayInExcess = false; - reenter = false; - } - - function setRepayLoan(bool _repayLoan) public { - repayLoan = _repayLoan; - } - - function setRepayInExcess(bool _repayInExcess) public { - repayInExcess = _repayInExcess; - } - - function setReenter(bool _reenter) public { - reenter = _reenter; - } - - // Repays loan unless setRepayLoan was called with 'false' - function receiveFlashLoan( - IERC20[] memory tokens, - uint256[] memory amounts, - uint256[] memory feeAmounts, - bytes memory userData - ) external override { - for (uint256 i = 0; i < tokens.length; ++i) { - IERC20 token = tokens[i]; - uint256 amount = amounts[i]; - uint256 feeAmount = feeAmounts[i]; - - require(token.balanceOf(address(this)) == amount, "INVALID_FLASHLOAN_BALANCE"); - - if (reenter) { - IVault(msg.sender).flashLoan(IFlashLoanRecipient(address(this)), tokens, amounts, userData); - } - - // The recipient will mint the fees it pays - TestToken(address(token)).mint(address(this), repayInExcess ? feeAmount.add(1) : feeAmount); - - uint256 totalDebt = amount.add(feeAmount); - - if (!repayLoan) { - totalDebt = totalDebt.sub(1); - } else if (repayInExcess) { - totalDebt = totalDebt.add(1); - } - - token.safeTransfer(vault, totalDebt); - } - } -} diff --git a/pkg/vault/test/Fees.test.ts b/pkg/vault/test/Fees.test.ts index 898accd20..bc091c1c3 100644 --- a/pkg/vault/test/Fees.test.ts +++ b/pkg/vault/test/Fees.test.ts @@ -36,7 +36,6 @@ describe('Fees', () => { describe('set fees', () => { const MAX_SWAP_FEE_PERCENTAGE = bn(50e16); // 50% - const MAX_FLASH_LOAN_FEE_PERCENTAGE = bn(1e16); // 1% context('when the sender is allowed', () => { context('when the given input is valid', async () => { @@ -56,25 +55,6 @@ describe('Fees', () => { }); }); }); - - describe('flash loan fee', () => { - it('sets the percentage properly', async () => { - await vault.setFlashLoanFeePercentage(MAX_FLASH_LOAN_FEE_PERCENTAGE, { from: admin }); - - const flashLoanFeePercentage = await vault.getFlashLoanFeePercentage(); - expect(flashLoanFeePercentage).to.equal(MAX_FLASH_LOAN_FEE_PERCENTAGE); - }); - - it('emits an event', async () => { - const receipt = await ( - await vault.setFlashLoanFeePercentage(MAX_FLASH_LOAN_FEE_PERCENTAGE, { from: admin }) - ).wait(); - - expectEvent.inReceipt(receipt, 'FlashLoanFeePercentageChanged', { - newFlashLoanFeePercentage: MAX_FLASH_LOAN_FEE_PERCENTAGE, - }); - }); - }); }); context('when the given input is invalid', async () => { @@ -85,14 +65,6 @@ describe('Fees', () => { 'SWAP_FEE_PERCENTAGE_TOO_HIGH' ); }); - - it('reverts if the flash loan fee percentage is above the maximum', async () => { - const badFlashLoanFeePercentage = MAX_FLASH_LOAN_FEE_PERCENTAGE.add(1); - - await expect(vault.setFlashLoanFeePercentage(badFlashLoanFeePercentage, { from: admin })).to.be.revertedWith( - 'FLASH_LOAN_FEE_PERCENTAGE_TOO_HIGH' - ); - }); }); }); @@ -101,9 +73,6 @@ describe('Fees', () => { await expect(vault.setSwapFeePercentage(MAX_SWAP_FEE_PERCENTAGE, { from: other })).to.be.revertedWith( 'SENDER_NOT_ALLOWED' ); - await expect(vault.setFlashLoanFeePercentage(MAX_SWAP_FEE_PERCENTAGE, { from: other })).to.be.revertedWith( - 'SENDER_NOT_ALLOWED' - ); }); }); }); diff --git a/pkg/vault/test/FlashLoan.test.ts b/pkg/vault/test/FlashLoan.test.ts deleted file mode 100644 index b0e264bc8..000000000 --- a/pkg/vault/test/FlashLoan.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { ethers } from 'hardhat'; -import { expect } from 'chai'; -import { Contract, ContractTransaction } from 'ethers'; -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address'; - -import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; - -import * as expectEvent from '@balancer-labs/v2-helpers/src/test/expectEvent'; -import { deploy, deployedAt } from '@balancer-labs/v2-helpers/src/contract'; -import { actionId } from '@balancer-labs/v2-helpers/src/models/misc/actions'; -import { expectBalanceChange } from '@balancer-labs/v2-helpers/src/test/tokenBalance'; -import { bn, divCeil, fp, fpMul, FP_100_PCT } from '@balancer-labs/v2-helpers/src/numbers'; -import { ANY_ADDRESS, ZERO_ADDRESS } from '@balancer-labs/v2-helpers/src/constants'; -import Vault from '@balancer-labs/v2-helpers/src/models/vault/Vault'; -import { sharedBeforeEach } from '@balancer-labs/v2-common/sharedBeforeEach'; - -describe('Flash Loans', () => { - let admin: SignerWithAddress, minter: SignerWithAddress, feeSetter: SignerWithAddress, other: SignerWithAddress; - let authorizer: Contract, vault: Contract, recipient: Contract, feesCollector: Contract; - let tokens: TokenList; - - before('setup', async () => { - [, admin, minter, feeSetter, other] = await ethers.getSigners(); - }); - - sharedBeforeEach('deploy vault & tokens', async () => { - ({ instance: vault, authorizer } = await Vault.create({ admin })); - feesCollector = await deployedAt('ProtocolFeesCollector', await vault.getProtocolFeesCollector()); - recipient = await deploy('MockFlashLoanRecipient', { from: other, args: [vault.address] }); - - const action = await actionId(feesCollector, 'setFlashLoanFeePercentage'); - await authorizer.connect(admin).grantPermission(action, feeSetter.address, ANY_ADDRESS); - - tokens = await TokenList.create(['DAI', 'MKR'], { from: minter, sorted: true }); - await tokens.mint({ from: minter, to: vault, amount: bn(100e18) }); - }); - - context('with no protocol fees', () => { - sharedBeforeEach(async () => { - await feesCollector.connect(feeSetter).setFlashLoanFeePercentage(0); - }); - - it('causes no net balance change on the Vault', async () => { - await expectBalanceChange( - () => vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [bn(1e18)], '0x10'), - tokens, - { account: vault } - ); - }); - - it('all balance can be loaned', async () => { - const tx = await vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [bn(100e18)], '0x10'); - const receipt = await tx.wait(); - - expectEvent.inReceipt(receipt, 'FlashLoan', { - recipient: recipient.address, - token: tokens.DAI.address, - amount: bn(100e18), - feeAmount: 0, - }); - }); - - it('reverts if the loan is larger than available balance', async () => { - await expect( - vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [bn(100e18).add(1)], '0x10') - ).to.be.revertedWith('INSUFFICIENT_FLASH_LOAN_BALANCE'); - }); - - it('reverts if the borrower does not repay the loan', async () => { - await recipient.setRepayLoan(false); - - await expect( - vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [bn(1e18)], '0x10') - ).to.be.revertedWith('INVALID_POST_LOAN_BALANCE'); - }); - }); - - context('with protocol fees', () => { - const feePercentage = fp(0.005); // 0.5% - - sharedBeforeEach(async () => { - await feesCollector.connect(feeSetter).setFlashLoanFeePercentage(feePercentage); - }); - - it('zero loans are possible', async () => { - const loan = 0; - const feeAmount = 0; - - await expectBalanceChange( - () => vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [loan], '0x10'), - tokens, - { account: vault } - ); - - expect((await feesCollector.getCollectedFeeAmounts([tokens.DAI.address]))[0]).to.equal(feeAmount); - }); - - it('zero loans are possible', async () => { - const loan = 0; - const feeAmount = 0; - - await expectBalanceChange( - () => vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [loan], '0x10'), - tokens, - { account: vault } - ); - - expect((await feesCollector.getCollectedFeeAmounts([tokens.DAI.address]))[0]).to.equal(feeAmount); - }); - - it('the fees module receives protocol fees', async () => { - const loan = bn(1e18); - const feeAmount = divCeil(loan.mul(feePercentage), FP_100_PCT); - - await expectBalanceChange( - () => vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [loan], '0x10'), - tokens, - { account: feesCollector, changes: { DAI: feeAmount } } - ); - - expect((await feesCollector.getCollectedFeeAmounts([tokens.DAI.address]))[0]).to.equal(feeAmount); - }); - - it('protocol fees are rounded up', async () => { - const loan = bn(1); - const feeAmount = bn(1); // In this extreme case, fees account for the full loan - - await expectBalanceChange( - () => vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [loan], '0x10'), - tokens, - { account: feesCollector, changes: { DAI: feeAmount } } - ); - - expect((await feesCollector.getCollectedFeeAmounts([tokens.DAI.address]))[0]).to.equal(feeAmount); - }); - - it('excess fees can be paid', async () => { - await recipient.setRepayInExcess(true); - - // The recipient pays one extra token - const feeAmount = fpMul(bn(1e18), feePercentage).add(1); - - const tx: ContractTransaction = await expectBalanceChange( - () => vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [bn(1e18)], '0x10'), - tokens, - { account: feesCollector, changes: { DAI: feeAmount } } - ); - - expect(await feesCollector.getCollectedFeeAmounts([tokens.DAI.address])).to.deep.equal([feeAmount]); - - expectEvent.inReceipt(await tx.wait(), 'FlashLoan', { - recipient: recipient.address, - token: tokens.DAI.address, - amount: bn(1e18), - feeAmount, - }); - }); - - it('all balance can be loaned', async () => { - await vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [bn(100e18)], '0x10'); - }); - - it('reverts if the borrower does not repay the loan', async () => { - await recipient.setRepayLoan(false); - - await expect( - vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [bn(1e18)], '0x10') - ).to.be.revertedWith('INSUFFICIENT_FLASH_LOAN_FEE_AMOUNT'); - }); - - it('reverts if the borrower reenters the Vault', async () => { - await recipient.setReenter(true); - - await expect( - vault.connect(other).flashLoan(recipient.address, [tokens.DAI.address], [bn(1e18)], '0x10') - ).to.be.revertedWith('REENTRANCY'); - }); - - describe('multi asset loan', () => { - it('the Vault receives protocol fees proportional to each loan', async () => { - const amounts = [1e18, 2e18].map(bn); - const feeAmounts = amounts.map((amount) => fpMul(amount, feePercentage)); - - await expectBalanceChange( - () => - vault - .connect(other) - .flashLoan(recipient.address, [tokens.DAI.address, tokens.MKR.address], amounts, '0x10'), - tokens, - { account: feesCollector, changes: { DAI: feeAmounts[0], MKR: feeAmounts[1] } } - ); - - expect(await feesCollector.getCollectedFeeAmounts([tokens.DAI.address])).to.deep.equal([feeAmounts[0]]); - expect(await feesCollector.getCollectedFeeAmounts([tokens.MKR.address])).to.deep.equal([feeAmounts[1]]); - }); - - it('all balance can be loaned', async () => { - await vault - .connect(other) - .flashLoan(recipient.address, [tokens.DAI.address, tokens.MKR.address], [bn(100e18), bn(100e18)], '0x10'); - }); - - it('reverts if tokens are not unique', async () => { - await expect( - vault - .connect(other) - .flashLoan(recipient.address, [tokens.DAI.address, tokens.DAI.address], [bn(100e18), bn(100e18)], '0x10') - ).to.be.revertedWith('UNSORTED_TOKENS'); - }); - - it('reverts if tokens are not sorted', async () => { - await expect( - vault - .connect(other) - .flashLoan(recipient.address, [tokens.MKR.address, tokens.DAI.address], [bn(100e18), bn(100e18)], '0x10') - ).to.be.revertedWith('UNSORTED_TOKENS'); - }); - - it('reverts if a token is invalid', async () => { - await expect( - vault - .connect(other) - .flashLoan(recipient.address, [tokens.MKR.address, ZERO_ADDRESS], [bn(100e18), bn(100e18)], '0x10') - ).to.be.revertedWith('ZERO_TOKEN'); - }); - }); - }); -}); diff --git a/pvt/helpers/src/models/vault/Vault.ts b/pvt/helpers/src/models/vault/Vault.ts index 71c70e84e..4006e0e2d 100644 --- a/pvt/helpers/src/models/vault/Vault.ts +++ b/pvt/helpers/src/models/vault/Vault.ts @@ -211,10 +211,9 @@ export default class Vault { return feesCollector.withdrawCollectedFees(tokens, amounts, TypesConverter.toAddress(recipient)); } - async getProtocolFeePercentages(): Promise<{ swapFeePercentage: BigNumber; flashLoanFeePercentage: BigNumber }> { + async getProtocolFeePercentages(): Promise<{ swapFeePercentage: BigNumber }> { return { swapFeePercentage: await this.getSwapFeePercentage(), - flashLoanFeePercentage: await this.getFlashLoanFeePercentage(), }; } @@ -222,10 +221,6 @@ export default class Vault { return this.getFeesProvider().getFeeTypePercentage(ProtocolFee.SWAP); } - async getFlashLoanFeePercentage(): Promise { - return this.getFeesProvider().getFeeTypePercentage(ProtocolFee.FLASH_LOAN); - } - async getFeesCollector(): Promise { if (!this.feesCollector) { const instance = await this.instance.getProtocolFeesCollector(); @@ -253,22 +248,6 @@ export default class Vault { return instance.setSwapFeePercentage(swapFeePercentage); } - async setFlashLoanFeePercentage( - flashLoanFeePercentage: BigNumber, - { from }: TxParams = {} - ): Promise { - const feesCollector = await this.getFeesCollector(); - const id = await actionId(feesCollector, 'setFlashLoanFeePercentage'); - - if (this.authorizer && this.admin && !(await this.hasPermissionGlobally(id, this.admin))) { - await this.grantPermissionGlobally(id, this.admin); - } - - const sender = from || this.admin; - const instance = sender ? feesCollector.connect(sender) : feesCollector; - return instance.setFlashLoanFeePercentage(flashLoanFeePercentage); - } - async setFeeTypePercentage(feeType: number, value: BigNumberish): Promise { if (!this.admin) throw Error("Missing Vault's admin"); @@ -287,12 +266,6 @@ export default class Vault { feeCollector.address ); - await this.grantPermissionIfNeeded( - await actionId(feeCollector, 'setFlashLoanFeePercentage'), - feeProvider.address, - feeCollector.address - ); - await feeProvider.connect(this.admin).setFeeTypePercentage(feeType, bn(value)); } diff --git a/pvt/helpers/src/models/vault/types.ts b/pvt/helpers/src/models/vault/types.ts index b66deba3e..0ffb04515 100644 --- a/pvt/helpers/src/models/vault/types.ts +++ b/pvt/helpers/src/models/vault/types.ts @@ -86,7 +86,6 @@ export type QueryBatchSwap = { export enum ProtocolFee { SWAP = 0, - FLASH_LOAN = 1, YIELD = 2, AUM = 3, } From 09539037ec206f22585eac9465ae6a657a22cbd0 Mon Sep 17 00:00:00 2001 From: dcpp Date: Tue, 16 Sep 2025 14:36:41 +0300 Subject: [PATCH 13/13] Add Vault.exitPoolEmergency function --- pkg/interfaces/contracts/vault/IVault.sol | 7 +++++ pkg/vault/contracts/PoolBalances.sol | 29 ++++++++++++++++++++ pkg/vault/contracts/Vault.sol | 4 +++ pkg/vault/test/ExitPool.test.ts | 33 +++++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/pkg/interfaces/contracts/vault/IVault.sol b/pkg/interfaces/contracts/vault/IVault.sol index 30fc0ac3c..d217d6f96 100644 --- a/pkg/interfaces/contracts/vault/IVault.sol +++ b/pkg/interfaces/contracts/vault/IVault.sol @@ -447,6 +447,13 @@ interface IVault is ISignaturesValidator, ITemporarilyPausable, IAuthentication enum PoolBalanceChangeKind { JOIN, EXIT } + /* Withdraws all pool token balances to caller. The caller should be authorized to call this function. + * The call doesn't burn pool tokens and doesn't change totalSupply. + * + * Emits a `PoolBalanceChanged` event. + */ + function exitPoolEmergency(bytes32 poolId) external; + // Swaps // // Users can swap tokens with Pools by calling the `swap` and `batchSwap` functions. To do this, diff --git a/pkg/vault/contracts/PoolBalances.sol b/pkg/vault/contracts/PoolBalances.sol index f24a5a6d3..f99e10082 100644 --- a/pkg/vault/contracts/PoolBalances.sol +++ b/pkg/vault/contracts/PoolBalances.sol @@ -20,6 +20,7 @@ import "@balancer-labs/v2-interfaces/contracts/solidity-utils/openzeppelin/IERC2 import "@balancer-labs/v2-interfaces/contracts/vault/IBasePool.sol"; import "@balancer-labs/v2-solidity-utils/contracts/openzeppelin/ReentrancyGuard.sol"; +import "@balancer-labs/v2-solidity-utils/contracts/helpers/ERC20Helpers.sol"; import "@balancer-labs/v2-solidity-utils/contracts/helpers/InputHelpers.sol"; import "@balancer-labs/v2-solidity-utils/contracts/openzeppelin/SafeERC20.sol"; import "@balancer-labs/v2-solidity-utils/contracts/math/Math.sol"; @@ -153,6 +154,34 @@ abstract contract PoolBalances is Fees, ReentrancyGuard, PoolTokens, UserBalance ); } + function _exitPoolEmergency(bytes32 poolId, address payable recipient) internal { + (IERC20[] memory tokens, bytes32[] memory rawBalances) = _getPoolTokens(poolId); + (uint256[] memory balances, ) = rawBalances.totalsAndLastChangeBlock(); + PoolBalanceChange memory change = PoolBalanceChange(_asIAsset(tokens), + new uint256[](tokens.length), new bytes(0), false); + bytes32[] memory finalBalances = _processExitPoolTransfers( + recipient, change, rawBalances, balances, new uint256[](tokens.length)); + + // All that remains is storing the new Pool balances. + PoolSpecialization specialization = _getPoolSpecialization(poolId); + if (specialization == PoolSpecialization.TWO_TOKEN) { + _setTwoTokenPoolCashBalances(poolId, tokens[0], finalBalances[0], tokens[1], finalBalances[1]); + } else if (specialization == PoolSpecialization.MINIMAL_SWAP_INFO) { + _setMinimalSwapInfoPoolBalances(poolId, tokens, finalBalances); + } else { + // PoolSpecialization.GENERAL + _setGeneralPoolBalances(poolId, finalBalances); + } + + emit PoolBalanceChanged( + poolId, + recipient, + tokens, + _unsafeCastToInt256(balances, false), + new uint256[](tokens.length) + ); + } + /** * @dev Calls the corresponding Pool hook to get the amounts in/out plus protocol fee amounts, and performs the * associated token transfers and fee payments, returning the Pool's final balances. diff --git a/pkg/vault/contracts/Vault.sol b/pkg/vault/contracts/Vault.sol index 3b0cee7b9..4390d5ac9 100644 --- a/pkg/vault/contracts/Vault.sol +++ b/pkg/vault/contracts/Vault.sol @@ -73,4 +73,8 @@ contract Vault is VaultAuthorization, Swaps { function WETH() external view override returns (IWETH) { return _WETH(); } + + function exitPoolEmergency(bytes32 poolId) external override authenticate { + _exitPoolEmergency(poolId, msg.sender); + } } diff --git a/pkg/vault/test/ExitPool.test.ts b/pkg/vault/test/ExitPool.test.ts index 4097561bd..b0976b501 100644 --- a/pkg/vault/test/ExitPool.test.ts +++ b/pkg/vault/test/ExitPool.test.ts @@ -170,6 +170,10 @@ describe('Exit Pool', () => { } context('when called incorrectly', () => { + it('reverts if emergency exit with not authorized admin', async () => { + await expect(vault.connect(creator).exitPoolEmergency(poolId)).to.be.revertedWith('SENDER_NOT_ALLOWED'); + }); + it('reverts if the pool ID does not exist', async () => { await expect(exitPool({ poolId: ethers.utils.id('invalid') })).to.be.revertedWith('INVALID_POOL_ID'); }); @@ -239,6 +243,35 @@ describe('Exit Pool', () => { }); }); + context('with authorized admin', () => { + it('exit pool emergency', async () => { + const action = await actionId(vault, 'exitPoolEmergency'); + await authorizer.connect(admin).grantPermission(action, recipient.address, vault.address); + + { + const { tokens, balances } = await vault.getPoolTokens(poolId); + balances.forEach((balance: number, i: number) => { + expect(balance).to.be.gt(0); + }); + tokens.forEach(async (token: Token, i: number) => { + await expect(token.balanceOf(recipient.address)).to.be.eq(0); + }); + } + + await vault.connect(recipient).exitPoolEmergency(poolId); + + { + const { tokens, balances } = await vault.getPoolTokens(poolId); + balances.forEach((balance: number, i: number) => { + expect(balance).to.be.eq(0); + }); + tokens.forEach(async (token: Token, i: number) => { + await expect(token.balanceOf(recipient.address)).to.be.gt(0); + }); + } + }); + }); + context('with correct pool return values', () => { itExitsCorrectlyWithAndWithoutDueProtocolFeesAndInternalBalance(); });