From d54d14c40935fe513f73a8d4d63005596ae4bd13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Fri, 25 Nov 2022 12:10:28 -0300 Subject: [PATCH 1/3] Add missing function --- pvt/helpers/src/models/vault/Vault.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pvt/helpers/src/models/vault/Vault.ts b/pvt/helpers/src/models/vault/Vault.ts index bcc59cf8ec..e139844be2 100644 --- a/pvt/helpers/src/models/vault/Vault.ts +++ b/pvt/helpers/src/models/vault/Vault.ts @@ -77,8 +77,8 @@ export default class Vault { return this.instance.updateCash(poolId, cash); } - async updateManaged(poolId: string, managed: BigNumber[]): Promise { - return this.instance.updateManaged(poolId, managed); + async updateManaged(poolId: string, managedl: BigNumber[]): Promise { + return this.instance.updateManaged(poolId, managedl); } async minimalSwap(params: MinimalSwap): Promise { From 23c4fb17c9e64ffad9ad2309e79454fc4d74ad72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Fri, 25 Nov 2022 14:30:15 -0300 Subject: [PATCH 2/3] Add RecoveryModeHelper --- pkg/interfaces/CHANGELOG.md | 1 + .../pool-utils/IRecoveryModeHelper.sol | 35 ++++ .../contracts/RecoveryModeHelper.sol | 69 ++++++++ .../test/RecoveryModeHelper.test.ts | 157 ++++++++++++++++++ pvt/helpers/src/models/vault/Vault.ts | 4 +- 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol create mode 100644 pkg/pool-utils/contracts/RecoveryModeHelper.sol create mode 100644 pkg/pool-utils/test/RecoveryModeHelper.test.ts diff --git a/pkg/interfaces/CHANGELOG.md b/pkg/interfaces/CHANGELOG.md index c3d8a7d005..40bb592c0c 100644 --- a/pkg/interfaces/CHANGELOG.md +++ b/pkg/interfaces/CHANGELOG.md @@ -10,6 +10,7 @@ - Added `IRateProviderPool`. - Added `IVersion`. - Added `IFactoryCreatedPoolVersion`. +- Added `IRecoveryModeHelper`. ### New Features diff --git a/pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol b/pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol new file mode 100644 index 0000000000..7a4d93e7bc --- /dev/null +++ b/pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol @@ -0,0 +1,35 @@ +// 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; + +/** + * Interface for an auxiliary contract that computes Recovery Mode exits, removing logic from the core Pool contract + * that would otherwise take up a lot of bytecode size at the cost of some slight gas overhead. Since Recovery Mode + * exits are expected to be highly infrequent (and ideally never occur), this tradeoff makes sense. + */ +interface IRecoveryModeHelper { + /** + * @dev Computes a Recovery Mode Exit BPT and token amounts for a Pool. Only 'cash' balances are considered, to + * avoid scenarios where the last LPs to attempt to exit the Pool cannot because only 'managed' balance remains. + * + * The Pool is assumed to be a Composable Pool that uses ComposablePoolLib, meaning BPT will be its first token. It + * is also assumed that there is no 'managed' balance for BPT. + */ + function calcComposableRecoveryAmountsOut( + bytes32 poolId, + bytes memory userData, + uint256 totalSupply + ) external view returns (uint256 bptAmountInt, uint256[] memory amountsOut); +} diff --git a/pkg/pool-utils/contracts/RecoveryModeHelper.sol b/pkg/pool-utils/contracts/RecoveryModeHelper.sol new file mode 100644 index 0000000000..9089771440 --- /dev/null +++ b/pkg/pool-utils/contracts/RecoveryModeHelper.sol @@ -0,0 +1,69 @@ +// 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/solidity-utils/helpers/BalancerErrors.sol"; +import "@balancer-labs/v2-interfaces/contracts/vault/IVault.sol"; +import "@balancer-labs/v2-interfaces/contracts/pool-utils/BasePoolUserData.sol"; +import "@balancer-labs/v2-interfaces/contracts/pool-utils/IRecoveryModeHelper.sol"; + +import "@balancer-labs/v2-pool-weighted/contracts/WeightedMath.sol"; + +import "./lib/ComposablePoolLib.sol"; + +contract RecoveryModeHelper is IRecoveryModeHelper { + using BasePoolUserData for bytes; + + IVault private immutable _vault; + + constructor(IVault vault) { + _vault = vault; + } + + function getVault() public view returns (IVault) { + return _vault; + } + + function calcComposableRecoveryAmountsOut( + bytes32 poolId, + bytes memory userData, + uint256 totalSupply + ) external view override returns (uint256 bptAmountIn, uint256[] memory amountsOut) { + // As ManagedPool is a composable Pool, `_doRecoveryModeExit()` must use the virtual supply rather than the + // total supply to correctly distribute Pool assets proportionally. + // We must also ensure that we do not pay out a proportionaly fraction of the BPT held in the Vault, otherwise + // this would allow a user to recursively exit the pool using BPT they received from the previous exit. + + IVault vault = getVault(); + (IERC20[] memory registeredTokens, , ) = vault.getPoolTokens(poolId); + + uint256[] memory cashBalances = new uint256[](registeredTokens.length); + for (uint256 i = 0; i < registeredTokens.length; ++i) { + (uint256 cash, , , ) = vault.getPoolTokenInfo(poolId, registeredTokens[i]); + cashBalances[i] = cash; + } + + uint256 virtualSupply; + (virtualSupply, cashBalances) = ComposablePoolLib.dropBptFromBalances(totalSupply, cashBalances); + + bptAmountIn = userData.recoveryModeExit(); + + amountsOut = WeightedMath._calcTokensOutGivenExactBptIn(cashBalances, bptAmountIn, virtualSupply); + + // The Vault expects an array of amounts which includes BPT so prepend an empty element to this array. + amountsOut = ComposablePoolLib.prependZeroElement(amountsOut); + } +} diff --git a/pkg/pool-utils/test/RecoveryModeHelper.test.ts b/pkg/pool-utils/test/RecoveryModeHelper.test.ts new file mode 100644 index 0000000000..c09a3402d3 --- /dev/null +++ b/pkg/pool-utils/test/RecoveryModeHelper.test.ts @@ -0,0 +1,157 @@ +import { expect } from 'chai'; +import { BigNumber, Contract } from 'ethers'; +import { deploy } from '@balancer-labs/v2-helpers/src/contract'; +import Vault from '@balancer-labs/v2-helpers/src/models/vault/Vault'; +import { sharedBeforeEach } from '@balancer-labs/v2-common/sharedBeforeEach'; +import { randomAddress, ZERO_ADDRESS, ZERO_BYTES32 } from '@balancer-labs/v2-helpers/src/constants'; +import { BasePoolEncoder, PoolSpecialization } from '@balancer-labs/balancer-js'; +import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; +import { fp } from '@balancer-labs/v2-helpers/src/numbers'; +import { random } from 'lodash'; + +describe('RecoveryModeHelper', function () { + let vault: Vault; + let helper: Contract; + + sharedBeforeEach('deploy vault & tokens', async () => { + // We use a mocked Vault, as that lets us more easily mock cash and managed balances + vault = await Vault.create({ mocked: true }); + }); + + sharedBeforeEach('deploy helper', async () => { + helper = await deploy('RecoveryModeHelper', { args: [vault.address] }); + }); + + it("returns the vault's address", async () => { + expect(await helper.getVault()).to.equal(vault.address); + }); + + describe('calcComposableRecoveryAmountsOut', () => { + it('reverts if the poolId is invalid', async () => { + // This revert mode only happens with the real Vault, so we deploy one here for this test + const realVault = await Vault.create({}); + const realHelper = await deploy('RecoveryModeHelper', { args: [realVault.address] }); + await expect(realHelper.calcComposableRecoveryAmountsOut(ZERO_BYTES32, '0x', 0)).to.be.revertedWith( + 'INVALID_POOL_ID' + ); + }); + + it('reverts if the pool has no registered tokens', async () => { + // ComposablePools always have at least one token registered (the BPT) + const pool = await deploy('v2-vault/MockPool', { args: [vault.address, PoolSpecialization.GeneralPool] }); + await expect(helper.calcComposableRecoveryAmountsOut(await pool.getPoolId(), '0x', 0)).to.be.reverted; + }); + + it('reverts if the user data is not a recovery mode exit', async () => { + const pool = await deploy('v2-vault/MockPool', { args: [vault.address, PoolSpecialization.GeneralPool] }); + await pool.registerTokens([await randomAddress()], [ZERO_ADDRESS]); + + await expect(helper.calcComposableRecoveryAmountsOut(await pool.getPoolId(), '0xdeadbeef', 0)).to.be.reverted; + }); + + describe('with valid poolId and user data', () => { + let pool: Contract; + let poolId: string; + let tokens: TokenList; + + const totalSupply = fp(150); + const virtualSupply = fp(100); + const bptAmountIn = fp(20); // 20% of the virtual supply + + sharedBeforeEach('deploy mock pool', async () => { + pool = await deploy('v2-vault/MockPool', { args: [vault.address, PoolSpecialization.GeneralPool] }); + poolId = await pool.getPoolId(); + }); + + sharedBeforeEach('register tokens', async () => { + tokens = await TokenList.create(5); + + // ComposablePools register BPT as the first token + const poolTokens = [pool.address, ...tokens.addresses]; + await pool.registerTokens( + poolTokens, + poolTokens.map(() => ZERO_ADDRESS) + ); + }); + + describe('with no managed balance', async () => { + let balances: Array; + + sharedBeforeEach('set cash', async () => { + balances = tokens.map(() => fp(random(1, 50))); + + // The first token is BPT, and its Pool balance is the difference between total and virtual supply (i.e. the + // preminted tokens). + await vault.updateCash(poolId, [totalSupply.sub(virtualSupply), ...balances]); + await vault.updateManaged(poolId, [0, ...tokens.map(() => 0)]); + }); + + it('returns the encoded BPT amount in', async () => { + const { bptAmountIn: actualBptAmountIn } = await helper.calcComposableRecoveryAmountsOut( + poolId, + BasePoolEncoder.recoveryModeExit(bptAmountIn), + totalSupply + ); + + expect(actualBptAmountIn).to.equal(bptAmountIn); + }); + + it('returns proportional amounts out', async () => { + const { amountsOut: actualAmountsOut } = await helper.calcComposableRecoveryAmountsOut( + poolId, + BasePoolEncoder.recoveryModeExit(bptAmountIn), + totalSupply + ); + + // bptAmountIn corresponds to 20% of the virtual supply + const expectedTokenAmountsOut = balances.map((amount) => amount.div(5)); + // The first token in a Composable Pool is BPT + const expectedAmountsOut = [0, ...expectedTokenAmountsOut]; + + expect(actualAmountsOut).to.deep.equal(expectedAmountsOut); + }); + }); + + describe('with managed balance', async () => { + let cashBalances: Array; + let managedBalances: Array; + + sharedBeforeEach('set balances', async () => { + cashBalances = tokens.map(() => fp(random(1, 50))); + managedBalances = tokens.map(() => fp(random(1, 50))); + + // The first token is BPT, and its Pool balance is the difference between total and virtual supply (i.e. the + // preminted tokens). + await vault.updateCash(poolId, [totalSupply.sub(virtualSupply), ...cashBalances]); + // There's no managed balance for BPT + await vault.updateManaged(poolId, [0, ...managedBalances]); + }); + + it('returns the encoded BPT amount in', async () => { + const { bptAmountIn: actualBptAmountIn } = await helper.calcComposableRecoveryAmountsOut( + poolId, + BasePoolEncoder.recoveryModeExit(bptAmountIn), + totalSupply + ); + + expect(actualBptAmountIn).to.equal(bptAmountIn); + }); + + it('returns proportional cash amounts out', async () => { + const { amountsOut: actualAmountsOut } = await helper.calcComposableRecoveryAmountsOut( + poolId, + BasePoolEncoder.recoveryModeExit(bptAmountIn), + totalSupply + ); + + // bptAmountIn corresponds to 20% of the virtual supply + const expectedTokenAmountsOut = cashBalances.map((amount) => amount.div(5)); + // The first token in a Composable Pool is BPT + const expectedAmountsOut = [0, ...expectedTokenAmountsOut]; + + expect(actualAmountsOut).to.deep.equal(expectedAmountsOut); + }); + }); + }); + }); +}); diff --git a/pvt/helpers/src/models/vault/Vault.ts b/pvt/helpers/src/models/vault/Vault.ts index e139844be2..d23a7d869f 100644 --- a/pvt/helpers/src/models/vault/Vault.ts +++ b/pvt/helpers/src/models/vault/Vault.ts @@ -73,11 +73,11 @@ export default class Vault { return this.instance.getPoolTokenInfo(poolId, typeof token == 'string' ? token : token.address); } - async updateCash(poolId: string, cash: BigNumber[]): Promise { + async updateCash(poolId: string, cash: BigNumberish[]): Promise { return this.instance.updateCash(poolId, cash); } - async updateManaged(poolId: string, managedl: BigNumber[]): Promise { + async updateManaged(poolId: string, managedl: BigNumberish[]): Promise { return this.instance.updateManaged(poolId, managedl); } From 7500499be6cd78dae6629442371e15a6ab33241d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Mon, 28 Nov 2022 17:24:14 -0300 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: EndymionJkb --- pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol | 2 +- pkg/pool-utils/contracts/RecoveryModeHelper.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol b/pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol index 7a4d93e7bc..68f1bb681c 100644 --- a/pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol +++ b/pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol @@ -31,5 +31,5 @@ interface IRecoveryModeHelper { bytes32 poolId, bytes memory userData, uint256 totalSupply - ) external view returns (uint256 bptAmountInt, uint256[] memory amountsOut); + ) external view returns (uint256 bptAmountIn, uint256[] memory amountsOut); } diff --git a/pkg/pool-utils/contracts/RecoveryModeHelper.sol b/pkg/pool-utils/contracts/RecoveryModeHelper.sol index 9089771440..2103c69e1e 100644 --- a/pkg/pool-utils/contracts/RecoveryModeHelper.sol +++ b/pkg/pool-utils/contracts/RecoveryModeHelper.sol @@ -42,9 +42,9 @@ contract RecoveryModeHelper is IRecoveryModeHelper { bytes memory userData, uint256 totalSupply ) external view override returns (uint256 bptAmountIn, uint256[] memory amountsOut) { - // As ManagedPool is a composable Pool, `_doRecoveryModeExit()` must use the virtual supply rather than the + // As this is a composable Pool, `_doRecoveryModeExit()` must use the virtual supply rather than the // total supply to correctly distribute Pool assets proportionally. - // We must also ensure that we do not pay out a proportionaly fraction of the BPT held in the Vault, otherwise + // We must also ensure that we do not pay out a proportional fraction of the BPT held in the Vault, otherwise // this would allow a user to recursively exit the pool using BPT they received from the previous exit. IVault vault = getVault();