-
Notifications
You must be signed in to change notification settings - Fork 381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce RecoveryModeHelper #2068
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <http://www.gnu.org/licenses/>. | ||
|
||
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 bptAmountIn, uint256[] memory amountsOut); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <http://www.gnu.org/licenses/>. | ||
|
||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe consider adding this to the interface? Perhaps it's not necessary, but there are other interfaces where we do include it. |
||
return _vault; | ||
} | ||
|
||
function calcComposableRecoveryAmountsOut( | ||
bytes32 poolId, | ||
bytes memory userData, | ||
uint256 totalSupply | ||
) external view override returns (uint256 bptAmountIn, uint256[] memory amountsOut) { | ||
// 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 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(); | ||
(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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the same vein as the above comment about overfitting to pool types, we have several versions of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I want to have those in the library for recovery mode user data decoding. I didn't do that yet because it'll cause conflicts with the stable composable branch merge, but we should do it once that's in. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
// The Vault expects an array of amounts which includes BPT so prepend an empty element to this array. | ||
amountsOut = ComposablePoolLib.prependZeroElement(amountsOut); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<BigNumber>; | ||
|
||
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<BigNumber>; | ||
let managedBalances: Array<BigNumber>; | ||
|
||
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); | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -73,12 +73,12 @@ export default class Vault { | |||||||||
return this.instance.getPoolTokenInfo(poolId, typeof token == 'string' ? token : token.address); | ||||||||||
} | ||||||||||
|
||||||||||
async updateCash(poolId: string, cash: BigNumber[]): Promise<ContractTransaction> { | ||||||||||
async updateCash(poolId: string, cash: BigNumberish[]): Promise<ContractTransaction> { | ||||||||||
return this.instance.updateCash(poolId, cash); | ||||||||||
} | ||||||||||
|
||||||||||
async updateManaged(poolId: string, managed: BigNumber[]): Promise<ContractTransaction> { | ||||||||||
return this.instance.updateManaged(poolId, managed); | ||||||||||
async updateManaged(poolId: string, managedl: BigNumberish[]): Promise<ContractTransaction> { | ||||||||||
return this.instance.updateManaged(poolId, managedl); | ||||||||||
Comment on lines
+80
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch! I didn't see that one |
||||||||||
} | ||||||||||
|
||||||||||
async minimalSwap(params: MinimalSwap): Promise<ContractTransaction> { | ||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.