Skip to content
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

Merged
merged 3 commits into from
Nov 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/interfaces/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Added `IRateProviderPool`.
- Added `IVersion`.
- Added `IFactoryCreatedPoolVersion`.
- Added `IRecoveryModeHelper`.

### New Features

Expand Down
35 changes: 35 additions & 0 deletions pkg/interfaces/contracts/pool-utils/IRecoveryModeHelper.sol
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* avoid scenarios where the last LPs to attempt to exit the Pool cannot because only 'managed' balance remains.
* avoid scenarios where the last LPs to attempt to exit the Pool cannot do it 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);
}
69 changes: 69 additions & 0 deletions pkg/pool-utils/contracts/RecoveryModeHelper.sol
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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);
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 _calcTokensOutGivenExactBptIn function (and with stable it's embedded in the recovery mode exit). Since it's external anyway, maybe implement a version locally here (like the old _computeProportionalAmountsOut) for all pool types to use, so that we avoid using libraries specific to weighted pools?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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);
}
}
157 changes: 157 additions & 0 deletions pkg/pool-utils/test/RecoveryModeHelper.test.ts
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);
});
});
});
});
});
6 changes: 3 additions & 3 deletions pvt/helpers/src/models/vault/Vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async updateManaged(poolId: string, managedl: BigNumberish[]): Promise<ContractTransaction> {
return this.instance.updateManaged(poolId, managedl);
async updateManaged(poolId: string, managed: BigNumberish[]): Promise<ContractTransaction> {
return this.instance.updateManaged(poolId, managed);

Copy link
Collaborator

Choose a reason for hiding this comment

The 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> {
Expand Down