From 8c76fc095c98bed84e3348c81f7fa3d374f367ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Wed, 23 Mar 2022 12:12:59 -0300 Subject: [PATCH] Add BAL Token Holder contract (#1149) * Add BALTokenHolder and factory Co-authored-by: Tom French <15848336+TomAFrench@users.noreply.github.com> --- .../contracts/BALTokenHolder.sol | 86 +++++++++++++ .../contracts/BALTokenHolderFactory.sol | 58 +++++++++ .../contracts/interfaces/IBALTokenHolder.sol | 28 +++++ .../interfaces/IBALTokenHolderFactory.sol | 29 +++++ pkg/standalone-utils/package.json | 1 + .../test/BALTokenHolder.test.ts | 115 ++++++++++++++++++ .../test/BALTokenHolderFactory.test.ts | 81 ++++++++++++ yarn.lock | 3 +- 8 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 pkg/standalone-utils/contracts/BALTokenHolder.sol create mode 100644 pkg/standalone-utils/contracts/BALTokenHolderFactory.sol create mode 100644 pkg/standalone-utils/contracts/interfaces/IBALTokenHolder.sol create mode 100644 pkg/standalone-utils/contracts/interfaces/IBALTokenHolderFactory.sol create mode 100644 pkg/standalone-utils/test/BALTokenHolder.test.ts create mode 100644 pkg/standalone-utils/test/BALTokenHolderFactory.test.ts diff --git a/pkg/standalone-utils/contracts/BALTokenHolder.sol b/pkg/standalone-utils/contracts/BALTokenHolder.sol new file mode 100644 index 0000000000..9db7ad81c4 --- /dev/null +++ b/pkg/standalone-utils/contracts/BALTokenHolder.sol @@ -0,0 +1,86 @@ +// 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/Authentication.sol"; +import "@balancer-labs/v2-solidity-utils/contracts/openzeppelin/SafeERC20.sol"; + +import "@balancer-labs/v2-vault/contracts/interfaces/IVault.sol"; +import "@balancer-labs/v2-liquidity-mining/contracts/interfaces/IBalancerToken.sol"; + +import "./interfaces/IBALTokenHolder.sol"; + +/** + * @dev This contract simply holds the BAL token and delegates to Balancer Governance the permission to withdraw it. It + * is intended to serve as the recipient of automated BAL minting via the liquidity mining gauges, allowing for the + * final recipient of the funds to be configurable without having to alter the gauges themselves. + * + * There is also a separate auxiliary function to sweep any non-BAL tokens sent here by mistake. + */ +contract BALTokenHolder is IBALTokenHolder, Authentication { + using SafeERC20 for IERC20; + + IBalancerToken private immutable _balancerToken; + IVault private immutable _vault; + + string private _name; + + constructor( + IBalancerToken balancerToken, + IVault vault, + string memory name + ) Authentication(bytes32(uint256(address(this)))) { + // BALTokenHolder is often deployed from a factory for convenience, but it uses its own address instead of + // the factory's as a disambiguator to make sure the action IDs of all instances are unique, reducing likelihood + // of errors. + + _balancerToken = balancerToken; + _vault = vault; + _name = name; + } + + function getBalancerToken() external view returns (IBalancerToken) { + return _balancerToken; + } + + function getVault() public view returns (IVault) { + return _vault; + } + + function getName() external view returns (string memory) { + return _name; + } + + function getAuthorizer() public view returns (IAuthorizer) { + return getVault().getAuthorizer(); + } + + function _canPerform(bytes32 actionId, address account) internal view override returns (bool) { + return getAuthorizer().canPerform(actionId, account, address(this)); + } + + function withdrawFunds(address recipient, uint256 amount) external override authenticate { + IERC20(_balancerToken).safeTransfer(recipient, amount); + } + + function sweepTokens( + IERC20 token, + address recipient, + uint256 amount + ) external override authenticate { + require(token != _balancerToken, "Cannot sweep BAL"); + IERC20(token).safeTransfer(recipient, amount); + } +} diff --git a/pkg/standalone-utils/contracts/BALTokenHolderFactory.sol b/pkg/standalone-utils/contracts/BALTokenHolderFactory.sol new file mode 100644 index 0000000000..6efc1a5b04 --- /dev/null +++ b/pkg/standalone-utils/contracts/BALTokenHolderFactory.sol @@ -0,0 +1,58 @@ +// 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/Authentication.sol"; + +import "@balancer-labs/v2-vault/contracts/interfaces/IVault.sol"; +import "@balancer-labs/v2-liquidity-mining/contracts/interfaces/IBalancerToken.sol"; + +import "./BALTokenHolder.sol"; +import "./interfaces/IBALTokenHolderFactory.sol"; + +contract BALTokenHolderFactory is IBALTokenHolderFactory { + IBalancerToken private immutable _balancerToken; + IVault private immutable _vault; + + mapping(address => bool) private _factoryCreatedHolders; + + event BALTokenHolderCreated(BALTokenHolder balTokenHolder, string name); + + constructor(IBalancerToken balancerToken, IVault vault) { + _balancerToken = balancerToken; + _vault = vault; + } + + function getBalancerToken() public view override returns (IBalancerToken) { + return _balancerToken; + } + + function getVault() public view override returns (IVault) { + return _vault; + } + + function isHolderFromFactory(address holder) external view override returns (bool) { + return _factoryCreatedHolders[holder]; + } + + function create(string memory name) external override returns (IBALTokenHolder) { + BALTokenHolder holder = new BALTokenHolder(getBalancerToken(), getVault(), name); + + _factoryCreatedHolders[address(holder)] = true; + emit BALTokenHolderCreated(holder, name); + + return holder; + } +} diff --git a/pkg/standalone-utils/contracts/interfaces/IBALTokenHolder.sol b/pkg/standalone-utils/contracts/interfaces/IBALTokenHolder.sol new file mode 100644 index 0000000000..a6ec4968c1 --- /dev/null +++ b/pkg/standalone-utils/contracts/interfaces/IBALTokenHolder.sol @@ -0,0 +1,28 @@ +// 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/IAuthentication.sol"; +import "@balancer-labs/v2-solidity-utils/contracts/openzeppelin/IERC20.sol"; + +interface IBALTokenHolder is IAuthentication { + function withdrawFunds(address recipient, uint256 amount) external; + + function sweepTokens( + IERC20 token, + address recipient, + uint256 amount + ) external; +} diff --git a/pkg/standalone-utils/contracts/interfaces/IBALTokenHolderFactory.sol b/pkg/standalone-utils/contracts/interfaces/IBALTokenHolderFactory.sol new file mode 100644 index 0000000000..df160dc3a8 --- /dev/null +++ b/pkg/standalone-utils/contracts/interfaces/IBALTokenHolderFactory.sol @@ -0,0 +1,29 @@ +// 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-vault/contracts/interfaces/IVault.sol"; +import "@balancer-labs/v2-liquidity-mining/contracts/interfaces/IBalancerToken.sol"; +import "./IBALTokenHolder.sol"; + +interface IBALTokenHolderFactory { + function getBalancerToken() external view returns (IBalancerToken); + + function getVault() external view returns (IVault); + + function isHolderFromFactory(address holder) external view returns (bool); + + function create(string memory name) external returns (IBALTokenHolder); +} diff --git a/pkg/standalone-utils/package.json b/pkg/standalone-utils/package.json index 815a3aead4..8bc55666a8 100644 --- a/pkg/standalone-utils/package.json +++ b/pkg/standalone-utils/package.json @@ -28,6 +28,7 @@ "test:watch": "nodemon --ext js,ts --watch test --watch lib --exec 'clear && yarn test --no-compile'" }, "dependencies": { + "@balancer-labs/v2-liquidity-mining": "workspace:*", "@balancer-labs/v2-pool-utils": "workspace:*", "@balancer-labs/v2-solidity-utils": "workspace:*", "@balancer-labs/v2-vault": "workspace:*" diff --git a/pkg/standalone-utils/test/BALTokenHolder.test.ts b/pkg/standalone-utils/test/BALTokenHolder.test.ts new file mode 100644 index 0000000000..a8ff3a9565 --- /dev/null +++ b/pkg/standalone-utils/test/BALTokenHolder.test.ts @@ -0,0 +1,115 @@ +import { ethers } from 'hardhat'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address'; + +import { deploy, deployedAt } from '@balancer-labs/v2-helpers/src/contract'; +import { actionId } from '@balancer-labs/v2-helpers/src/models/misc/actions'; + +import Vault from '@balancer-labs/v2-helpers/src/models/vault/Vault'; +import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; +import { expectBalanceChange } from '@balancer-labs/v2-helpers/src/test/tokenBalance'; +import { Contract } from 'ethers'; +import { expect } from 'chai'; +import Token from '@balancer-labs/v2-helpers/src/models/tokens/Token'; + +describe('BALTokenHolder', function () { + let tokens: TokenList; + let BAL: Token, DAI: Token; + let vault: Vault; + let holder: Contract; + let admin: SignerWithAddress, authorized: SignerWithAddress, other: SignerWithAddress; + + const holderName = 'DAO Treasury'; + + before('get signers', async () => { + [, admin, authorized, other] = await ethers.getSigners(); + }); + + sharedBeforeEach(async () => { + // Deploy Balancer Vault + vault = await Vault.create({ admin }); + + // Deploy BAL token + tokens = await TokenList.create([{ symbol: 'BAL' }, { symbol: 'DAI' }]); + BAL = await tokens.findBySymbol('BAL'); + DAI = await tokens.findBySymbol('DAI'); + + holder = await deploy('BALTokenHolder', { args: [BAL.address, vault.address, holderName] }); + + // Deposit all tokens in the holder + await tokens.mint({ to: holder }); + }); + + it('returns the BAL address', async () => { + expect(await holder.getBalancerToken()).to.equal(BAL.address); + }); + + it('returns the address of the vault', async () => { + expect(await holder.getVault()).to.equal(vault.address); + }); + + it('returns its name', async () => { + expect(await holder.getName()).to.equal(holderName); + }); + + describe('withdrawFunds', () => { + context('when the caller is authorized', () => { + sharedBeforeEach(async () => { + const authorizer = await deployedAt('v2-vault/Authorizer', await vault.instance.getAuthorizer()); + const withdrawActionId = await actionId(holder, 'withdrawFunds'); + await authorizer.connect(admin).grantPermissions([withdrawActionId], authorized.address, [holder.address]); + }); + + it('sends funds to the recipient', async () => { + await expectBalanceChange(() => holder.connect(authorized).withdrawFunds(other.address, 100), tokens, { + account: other.address, + changes: { BAL: 100 }, + }); + }); + }); + + context('when the caller is not authorized', () => { + it('reverts', async () => { + await expect(holder.connect(other).withdrawFunds(other.address, 100)).to.be.revertedWith('SENDER_NOT_ALLOWED'); + }); + }); + }); + + describe('sweepTokens', () => { + context('when the caller is authorized', () => { + sharedBeforeEach(async () => { + const authorizer = await deployedAt('v2-vault/Authorizer', await vault.instance.getAuthorizer()); + const sweepActionId = await actionId(holder, 'sweepTokens'); + await authorizer.connect(admin).grantPermissions([sweepActionId], authorized.address, [holder.address]); + }); + + context('when the token is not BAL', () => { + it('sends funds to the recipient', async () => { + await expectBalanceChange( + () => holder.connect(authorized).sweepTokens(DAI.address, other.address, 100), + tokens, + { + account: other.address, + changes: { DAI: 100 }, + } + ); + }); + }); + + context('when the token is BAL', () => { + it('reverts', async () => { + await expect(holder.connect(authorized).sweepTokens(BAL.address, other.address, 100)).to.be.revertedWith( + 'Cannot sweep BAL' + ); + }); + }); + }); + + context('when the caller is not authorized', () => { + it('reverts', async () => { + await expect(holder.connect(other).sweepTokens(DAI.address, other.address, 100)).to.be.revertedWith( + 'SENDER_NOT_ALLOWED' + ); + }); + }); + }); +}); diff --git a/pkg/standalone-utils/test/BALTokenHolderFactory.test.ts b/pkg/standalone-utils/test/BALTokenHolderFactory.test.ts new file mode 100644 index 0000000000..5d45130d7e --- /dev/null +++ b/pkg/standalone-utils/test/BALTokenHolderFactory.test.ts @@ -0,0 +1,81 @@ +import { deploy, deployedAt } from '@balancer-labs/v2-helpers/src/contract'; +import { actionId } from '@balancer-labs/v2-helpers/src/models/misc/actions'; + +import Vault from '@balancer-labs/v2-helpers/src/models/vault/Vault'; +import TokenList from '@balancer-labs/v2-helpers/src/models/tokens/TokenList'; +import { Contract } from 'ethers'; +import { expect } from 'chai'; +import * as expectEvent from '@balancer-labs/v2-helpers/src/test/expectEvent'; +import Token from '@balancer-labs/v2-helpers/src/models/tokens/Token'; + +describe('BALTokenHolderFactory', function () { + let tokens: TokenList; + let BAL: Token; + let vault: Vault; + let factory: Contract; + + sharedBeforeEach(async () => { + // Deploy Balancer Vault + vault = await Vault.create(); + + // Deploy BAL token + tokens = await TokenList.create([{ symbol: 'BAL' }]); + BAL = await tokens.findBySymbol('BAL'); + + factory = await deploy('BALTokenHolderFactory', { args: [BAL.address, vault.address] }); + }); + + it('returns the BAL address', async () => { + expect(await factory.getBalancerToken()).to.equal(BAL.address); + }); + + it('returns the address of the vault', async () => { + expect(await factory.getVault()).to.equal(vault.address); + }); + + async function deployHolder(name: string): Promise { + const receipt = await (await factory.create(name)).wait(); + const { + args: { balTokenHolder: holder }, + } = expectEvent.inReceipt(receipt, 'BALTokenHolderCreated', { name }); + + return await deployedAt('BALTokenHolder', holder); + } + + describe('creation', () => { + it('emits an event', async () => { + const receipt = await (await factory.create('holder')).wait(); + expectEvent.inReceipt(receipt, 'BALTokenHolderCreated', { name: 'holder' }); + }); + + it('creates a holder with the same BAL and vault addresses', async () => { + const holder = await deployHolder('holder'); + + expect(await holder.getBalancerToken()).to.equal(BAL.address); + expect(await holder.getVault()).to.equal(vault.address); + }); + + it('creates a holder with name', async () => { + const holder = await deployHolder('holder'); + expect(await holder.getName()).to.equal('holder'); + }); + + it('creates holders with unique action IDs', async () => { + const first = await deployHolder('first'); + const second = await deployHolder('second'); + + expect(await actionId(first, 'withdrawFunds')).to.not.equal(await actionId(second, 'withdrawFunds')); + }); + }); + + describe('is holder from factory', () => { + it('returns true for holders created by the factory', async () => { + const holder = await deployHolder('holder'); + expect(await factory.isHolderFromFactory(holder.address)).to.equal(true); + }); + + it('returns false for other addresses', async () => { + expect(await factory.isHolderFromFactory(factory.address)).to.equal(false); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index b76b7836e1..7587bd670d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -332,7 +332,7 @@ __metadata: languageName: unknown linkType: soft -"@balancer-labs/v2-liquidity-mining@workspace:pkg/liquidity-mining": +"@balancer-labs/v2-liquidity-mining@workspace:*, @balancer-labs/v2-liquidity-mining@workspace:pkg/liquidity-mining": version: 0.0.0-use.local resolution: "@balancer-labs/v2-liquidity-mining@workspace:pkg/liquidity-mining" dependencies: @@ -624,6 +624,7 @@ __metadata: "@balancer-labs/balancer-js": "workspace:*" "@balancer-labs/v2-common": "workspace:*" "@balancer-labs/v2-helpers": "workspace:*" + "@balancer-labs/v2-liquidity-mining": "workspace:*" "@balancer-labs/v2-pool-utils": "workspace:*" "@balancer-labs/v2-solidity-utils": "workspace:*" "@balancer-labs/v2-vault": "workspace:*"