From 73ac8eac81ca2d32946e25f09f8635958ce12dce Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 1 Jul 2021 17:30:17 +0200 Subject: [PATCH 01/22] add VestingWallet in the contract/finance --- contracts/finance/VestingWallet.sol | 108 ++++++++++++++++ .../PaymentSplitter.test.js | 0 test/finance/VestingWallet.test.js | 119 ++++++++++++++++++ .../ERC20/extensions/ERC20Wrapper.test.js | 24 ++-- 4 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 contracts/finance/VestingWallet.sol rename test/{utils => finance}/PaymentSplitter.test.js (100%) create mode 100644 test/finance/VestingWallet.test.js diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol new file mode 100644 index 00000000000..62ca9309f61 --- /dev/null +++ b/contracts/finance/VestingWallet.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../token/ERC20/extensions/ERC20Votes.sol"; +import "../token/ERC20/utils/SafeERC20.sol"; +import "../utils/Context.sol"; + +/** + * @title VestingWallet + * @dev This contract handles the vesting of ERC20 tokens for a given beneficiary. Custody of multiple tokens can be + * given to this contract, which will release the token to the beneficiary following a given vesting schedule. The + * vesting scheduled is customizable through the `vestedAmount(address,uint256)` function. + * + * Any token transferred to this contract will follow the vesting schedule as if they were locked from the beginning. + * Consequently, if the vesting has already started, any amount of tokens sent to this contract will (at least partly) + * be immediately releasable. + * + * While tokens are locked, the beneficiary still has the ability to delegate the voting power potentially associated + * with these tokens. + */ +contract VestingWallet is Context { + event TokensReleased(address token, uint256 amount); + + mapping (address => uint256) private _released; + address private immutable _beneficiary; + uint256 private immutable _start; + uint256 private immutable _duration; + + modifier onlyBeneficiary() { + require(beneficiary() == _msgSender(), "VestingWallet: access restricted to beneficiary"); + _; + } + + /** + * @dev Set the beneficiary, start timestamp and vesting duration of the vesting wallet. + */ + constructor(address beneficiary_, uint256 start_, uint256 duration_) { + require(beneficiary_ != address(0), "VestingWallet: beneficiary is zero address"); + _beneficiary = beneficiary_; + _start = start_; + _duration = duration_; + } + + /** + * @dev Getter for the beneficiary address. + */ + function beneficiary() public view virtual returns (address) { + return _beneficiary; + } + + /** + * @dev Getter for the start timestamp. + */ + function start() public view virtual returns (uint256) { + return _start; + } + + /** + * @dev Getter for the vesting duration. + */ + function duration() public view virtual returns (uint256) { + return _duration; + } + + /** + * @dev Delegate the voting right of tokens currently vesting + */ + function delegate(address token, address delegatee) public virtual onlyBeneficiary() { + ERC20Votes(token).delegate(delegatee); + } + + /** + * @dev Amont of token already released + */ + function released(address token) public view returns (uint256) { + return _released[token]; + } + + /** + * @dev Release the tokens that have already vested. + */ + function release(address token) public virtual { + uint256 releasable = vestedAmount(token, block.timestamp) - released(token); + _released[token] += releasable; + emit TokensReleased(token, releasable); + SafeERC20.safeTransfer(IERC20(token), beneficiary(), releasable); + } + + /** + * @dev Calculates the amount that has already vested. Default implementation is a linear vesting curve. + */ + function vestedAmount(address token, uint256 timestamp) public virtual view returns (uint256) { + if (timestamp < start()) { + return 0; + } else if (timestamp >= start() + duration()) { + return _historicalBalance(token); + } else { + return _historicalBalance(token) * (timestamp - start()) / duration(); + } + } + + /** + * @dev Calculates the historical balance (current balance + already released balance). + */ + function _historicalBalance(address token) internal view returns (uint256) { + return IERC20(token).balanceOf(address(this)) + released(token); + } +} diff --git a/test/utils/PaymentSplitter.test.js b/test/finance/PaymentSplitter.test.js similarity index 100% rename from test/utils/PaymentSplitter.test.js rename to test/finance/PaymentSplitter.test.js diff --git a/test/finance/VestingWallet.test.js b/test/finance/VestingWallet.test.js new file mode 100644 index 00000000000..edb971798b1 --- /dev/null +++ b/test/finance/VestingWallet.test.js @@ -0,0 +1,119 @@ +const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); +const { expect } = require('chai'); + +const ERC20VotesMock = artifacts.require('ERC20VotesMock'); +const VestingWallet = artifacts.require('VestingWallet'); + +const min = (...args) => args.slice(1).reduce((x,y) => x.lt(y) ? x : y, args[0]); +const max = (...args) => args.slice(1).reduce((x,y) => x.gt(y) ? x : y, args[0]); + +contract('VestingWallet', function (accounts) { + const [ beneficiary, other ] = accounts; + + const amount = web3.utils.toBN(web3.utils.toWei('100')); + const duration = web3.utils.toBN(4 * 365 * 86400); // 4 years + + beforeEach(async function () { + this.start = (await time.latest()).addn(3600); // in 1 hour + this.token = await ERC20VotesMock.new('Name', 'Symbol'); + this.vesting = await VestingWallet.new(beneficiary, this.start, duration); + await this.token.mint(this.vesting.address, amount); + + this.schedule = Array(256).fill() + .map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(this.start)) + .map(timestamp => ({ + timestamp, + vested: min(amount.mul(timestamp.sub(this.start)).div(duration), amount), + })); + }); + + it('rejects zero address for beneficiary', async function () { + await expectRevert( + VestingWallet.new(constants.ZERO_ADDRESS, this.start, duration), + 'VestingWallet: beneficiary is zero address', + ); + }); + + it('check vesting contract', async function () { + expect(await this.vesting.beneficiary()).to.be.equal(beneficiary); + expect(await this.vesting.start()).to.be.bignumber.equal(this.start); + expect(await this.vesting.duration()).to.be.bignumber.equal(duration); + }); + + describe('vesting schedule', function () { + it('check vesting schedule', async function () { + for (const { timestamp, vested } of this.schedule) { + expect(await this.vesting.vestedAmount(this.token.address, timestamp)).to.be.bignumber.equal(vested); + } + }); + + it('execute vesting schedule', async function () { + const { tx } = await this.vesting.release(this.token.address); + await expectEvent.inTransaction(tx, this.vesting, 'TokensReleased', { + token: this.token.address, + amount: '0', + }); + await expectEvent.inTransaction(tx, this.token, 'Transfer', { + from: this.vesting.address, + to: beneficiary, + value: '0', + }); + + // on schedule + let released = web3.utils.toBN(0); + for (const { timestamp, vested } of this.schedule) { + await new Promise(resolve => web3.currentProvider.send({ + method: 'evm_setNextBlockTimestamp', + params: [ timestamp.toNumber() ], + }, resolve)); + + const { tx } = await this.vesting.release(this.token.address); + await expectEvent.inTransaction(tx, this.vesting, 'TokensReleased', { + token: this.token.address, + amount: vested.sub(released), + }); + await expectEvent.inTransaction(tx, this.token, 'Transfer', { + from: this.vesting.address, + to: beneficiary, + value: vested.sub(released), + }); + + released = vested; + + expect(await this.token.balanceOf(this.vesting.address)).to.be.bignumber.equal(amount.sub(vested)); + expect(await this.token.balanceOf(beneficiary)).to.be.bignumber.equal(vested); + } + }); + }); + + describe('delegate vote', function () { + it('wrong caller', async function () { + expect(await this.token.delegates(this.vesting.address)).to.be.equal(constants.ZERO_ADDRESS); + + await expectRevert( + this.vesting.delegate(this.token.address, other, { from: other }), + 'VestingWallet: access restricted to beneficiary', + ); + + expect(await this.token.delegates(this.vesting.address)).to.be.equal(constants.ZERO_ADDRESS); + }); + + it('authorized call', async function () { + expect(await this.token.delegates(this.vesting.address)).to.be.equal(constants.ZERO_ADDRESS); + + const { tx } = await this.vesting.delegate(this.token.address, other, { from: beneficiary }); + await expectEvent.inTransaction(tx, this.token, 'DelegateChanged', { + delegator: this.vesting.address, + fromDelegate: constants.ZERO_ADDRESS, + toDelegate: other, + }); + await expectEvent.inTransaction(tx, this.token, 'DelegateVotesChanged', { + delegate: other, + previousBalance: '0', + newBalance: amount, + }); + + expect(await this.token.delegates(this.vesting.address)).to.be.equal(other); + }); + }); +}); diff --git a/test/token/ERC20/extensions/ERC20Wrapper.test.js b/test/token/ERC20/extensions/ERC20Wrapper.test.js index 5b0edc0b7a9..05652342a80 100644 --- a/test/token/ERC20/extensions/ERC20Wrapper.test.js +++ b/test/token/ERC20/extensions/ERC20Wrapper.test.js @@ -44,12 +44,12 @@ contract('ERC20', function (accounts) { it('valid', async function () { await this.underlying.approve(this.token.address, initialSupply, { from: initialHolder }); const { tx } = await this.token.depositFor(initialHolder, initialSupply, { from: initialHolder }); - expectEvent.inTransaction(tx, this.underlying, 'Transfer', { + await expectEvent.inTransaction(tx, this.underlying, 'Transfer', { from: initialHolder, to: this.token.address, value: initialSupply, }); - expectEvent.inTransaction(tx, this.token, 'Transfer', { + await expectEvent.inTransaction(tx, this.token, 'Transfer', { from: ZERO_ADDRESS, to: initialHolder, value: initialSupply, @@ -74,12 +74,12 @@ contract('ERC20', function (accounts) { it('to other account', async function () { await this.underlying.approve(this.token.address, initialSupply, { from: initialHolder }); const { tx } = await this.token.depositFor(anotherAccount, initialSupply, { from: initialHolder }); - expectEvent.inTransaction(tx, this.underlying, 'Transfer', { + await expectEvent.inTransaction(tx, this.underlying, 'Transfer', { from: initialHolder, to: this.token.address, value: initialSupply, }); - expectEvent.inTransaction(tx, this.token, 'Transfer', { + await expectEvent.inTransaction(tx, this.token, 'Transfer', { from: ZERO_ADDRESS, to: anotherAccount, value: initialSupply, @@ -104,12 +104,12 @@ contract('ERC20', function (accounts) { const value = new BN(42); const { tx } = await this.token.withdrawTo(initialHolder, value, { from: initialHolder }); - expectEvent.inTransaction(tx, this.underlying, 'Transfer', { + await expectEvent.inTransaction(tx, this.underlying, 'Transfer', { from: this.token.address, to: initialHolder, value: value, }); - expectEvent.inTransaction(tx, this.token, 'Transfer', { + await expectEvent.inTransaction(tx, this.token, 'Transfer', { from: initialHolder, to: ZERO_ADDRESS, value: value, @@ -118,12 +118,12 @@ contract('ERC20', function (accounts) { it('entire balance', async function () { const { tx } = await this.token.withdrawTo(initialHolder, initialSupply, { from: initialHolder }); - expectEvent.inTransaction(tx, this.underlying, 'Transfer', { + await expectEvent.inTransaction(tx, this.underlying, 'Transfer', { from: this.token.address, to: initialHolder, value: initialSupply, }); - expectEvent.inTransaction(tx, this.token, 'Transfer', { + await expectEvent.inTransaction(tx, this.token, 'Transfer', { from: initialHolder, to: ZERO_ADDRESS, value: initialSupply, @@ -132,12 +132,12 @@ contract('ERC20', function (accounts) { it('to other account', async function () { const { tx } = await this.token.withdrawTo(anotherAccount, initialSupply, { from: initialHolder }); - expectEvent.inTransaction(tx, this.underlying, 'Transfer', { + await expectEvent.inTransaction(tx, this.underlying, 'Transfer', { from: this.token.address, to: anotherAccount, value: initialSupply, }); - expectEvent.inTransaction(tx, this.token, 'Transfer', { + await expectEvent.inTransaction(tx, this.token, 'Transfer', { from: initialHolder, to: ZERO_ADDRESS, value: initialSupply, @@ -151,7 +151,7 @@ contract('ERC20', function (accounts) { await this.token.depositFor(initialHolder, initialSupply, { from: initialHolder }); const { tx } = await this.token.recover(anotherAccount); - expectEvent.inTransaction(tx, this.token, 'Transfer', { + await expectEvent.inTransaction(tx, this.token, 'Transfer', { from: ZERO_ADDRESS, to: anotherAccount, value: '0', @@ -162,7 +162,7 @@ contract('ERC20', function (accounts) { await this.underlying.transfer(this.token.address, initialSupply, { from: initialHolder }); const { tx } = await this.token.recover(anotherAccount); - expectEvent.inTransaction(tx, this.token, 'Transfer', { + await expectEvent.inTransaction(tx, this.token, 'Transfer', { from: ZERO_ADDRESS, to: anotherAccount, value: initialSupply, From fac65ad58fa91ffb9e8722aca5498887696afc84 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 1 Jul 2021 17:34:43 +0200 Subject: [PATCH 02/22] fix lint --- contracts/finance/VestingWallet.sol | 16 ++++++++++------ test/finance/VestingWallet.test.js | 3 +-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index 62ca9309f61..1e9d85cb46a 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -21,7 +21,7 @@ import "../utils/Context.sol"; contract VestingWallet is Context { event TokensReleased(address token, uint256 amount); - mapping (address => uint256) private _released; + mapping(address => uint256) private _released; address private immutable _beneficiary; uint256 private immutable _start; uint256 private immutable _duration; @@ -34,7 +34,11 @@ contract VestingWallet is Context { /** * @dev Set the beneficiary, start timestamp and vesting duration of the vesting wallet. */ - constructor(address beneficiary_, uint256 start_, uint256 duration_) { + constructor( + address beneficiary_, + uint256 start_, + uint256 duration_ + ) { require(beneficiary_ != address(0), "VestingWallet: beneficiary is zero address"); _beneficiary = beneficiary_; _start = start_; @@ -77,8 +81,8 @@ contract VestingWallet is Context { } /** - * @dev Release the tokens that have already vested. - */ + * @dev Release the tokens that have already vested. + */ function release(address token) public virtual { uint256 releasable = vestedAmount(token, block.timestamp) - released(token); _released[token] += releasable; @@ -89,13 +93,13 @@ contract VestingWallet is Context { /** * @dev Calculates the amount that has already vested. Default implementation is a linear vesting curve. */ - function vestedAmount(address token, uint256 timestamp) public virtual view returns (uint256) { + function vestedAmount(address token, uint256 timestamp) public view virtual returns (uint256) { if (timestamp < start()) { return 0; } else if (timestamp >= start() + duration()) { return _historicalBalance(token); } else { - return _historicalBalance(token) * (timestamp - start()) / duration(); + return (_historicalBalance(token) * (timestamp - start())) / duration(); } } diff --git a/test/finance/VestingWallet.test.js b/test/finance/VestingWallet.test.js index edb971798b1..28751be5472 100644 --- a/test/finance/VestingWallet.test.js +++ b/test/finance/VestingWallet.test.js @@ -4,8 +4,7 @@ const { expect } = require('chai'); const ERC20VotesMock = artifacts.require('ERC20VotesMock'); const VestingWallet = artifacts.require('VestingWallet'); -const min = (...args) => args.slice(1).reduce((x,y) => x.lt(y) ? x : y, args[0]); -const max = (...args) => args.slice(1).reduce((x,y) => x.gt(y) ? x : y, args[0]); +const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); contract('VestingWallet', function (accounts) { const [ beneficiary, other ] = accounts; From 962423501fc81fd17ff31237186892f186f8a141 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 1 Jul 2021 19:51:22 +0200 Subject: [PATCH 03/22] rename constructor variables --- contracts/finance/VestingWallet.sol | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index 1e9d85cb46a..98811a05d76 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -35,14 +35,14 @@ contract VestingWallet is Context { * @dev Set the beneficiary, start timestamp and vesting duration of the vesting wallet. */ constructor( - address beneficiary_, - uint256 start_, - uint256 duration_ + address initialBeneficiary, + uint256 initialStart, + uint256 initialDuration ) { - require(beneficiary_ != address(0), "VestingWallet: beneficiary is zero address"); - _beneficiary = beneficiary_; - _start = start_; - _duration = duration_; + require(initialBeneficiary != address(0), "VestingWallet: beneficiary is zero address"); + _beneficiary = initialBeneficiary; + _start = initialStart; + _duration = initialDuration; } /** From 68ca2d6619d83dc06679932dda79424f592e6ac9 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 1 Jul 2021 21:44:38 +0200 Subject: [PATCH 04/22] Apply suggestions from code review Co-authored-by: Francisco Giordano --- contracts/finance/VestingWallet.sol | 8 +++++--- test/finance/VestingWallet.test.js | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index 98811a05d76..17f6da411e4 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -9,7 +9,7 @@ import "../utils/Context.sol"; * @title VestingWallet * @dev This contract handles the vesting of ERC20 tokens for a given beneficiary. Custody of multiple tokens can be * given to this contract, which will release the token to the beneficiary following a given vesting schedule. The - * vesting scheduled is customizable through the `vestedAmount(address,uint256)` function. + * vesting schedule is customizable through the {vestedAmount} function. * * Any token transferred to this contract will follow the vesting schedule as if they were locked from the beginning. * Consequently, if the vesting has already started, any amount of tokens sent to this contract will (at least partly) @@ -74,14 +74,16 @@ contract VestingWallet is Context { } /** - * @dev Amont of token already released + * @dev Amount of token already released */ - function released(address token) public view returns (uint256) { + function released(address token) public view virtual returns (uint256) { return _released[token]; } /** * @dev Release the tokens that have already vested. + * + * Emits a {TokensReleased} event. */ function release(address token) public virtual { uint256 releasable = vestedAmount(token, block.timestamp) - released(token); diff --git a/test/finance/VestingWallet.test.js b/test/finance/VestingWallet.test.js index 28751be5472..16e0bcae845 100644 --- a/test/finance/VestingWallet.test.js +++ b/test/finance/VestingWallet.test.js @@ -22,7 +22,7 @@ contract('VestingWallet', function (accounts) { .map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(this.start)) .map(timestamp => ({ timestamp, - vested: min(amount.mul(timestamp.sub(this.start)).div(duration), amount), + vested: min(amount, amount.mul(timestamp.sub(this.start)).div(duration)), })); }); From a0ca250aab0035c3e491673f8b95675abd78ee43 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 1 Jul 2021 21:48:21 +0200 Subject: [PATCH 05/22] address issue raised in the PR --- contracts/finance/ERC20VestingWallet.sol | 112 ++++++++++++++++++ ...let.test.js => ERC20VestingWallet.test.js} | 12 +- 2 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 contracts/finance/ERC20VestingWallet.sol rename test/finance/{VestingWallet.test.js => ERC20VestingWallet.test.js} (91%) diff --git a/contracts/finance/ERC20VestingWallet.sol b/contracts/finance/ERC20VestingWallet.sol new file mode 100644 index 00000000000..fc208d7a50e --- /dev/null +++ b/contracts/finance/ERC20VestingWallet.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../token/ERC20/extensions/ERC20Votes.sol"; +import "../token/ERC20/utils/SafeERC20.sol"; +import "../utils/Context.sol"; + +/** + * @title VestingWallet + * @dev This contract handles the vesting of ERC20 tokens for a given beneficiary. Custody of multiple tokens can be + * given to this contract, which will release the token to the beneficiary following a given vesting schedule. The + * vesting scheduled is customizable through the `vestedAmount(address,uint256)` function. + * + * Any token transferred to this contract will follow the vesting schedule as if they were locked from the beginning. + * Consequently, if the vesting has already started, any amount of tokens sent to this contract will (at least partly) + * be immediately releasable. + * + * While tokens are locked, the beneficiary still has the ability to delegate the voting power potentially associated + * with these tokens. + */ +contract ERC20VestingWallet is Context { + event TokensReleased(address token, uint256 amount); + + mapping(address => uint256) private _released; + address private immutable _beneficiary; + uint256 private immutable _start; + uint256 private immutable _duration; + + modifier onlyBeneficiary() { + require(beneficiary() == _msgSender(), "ERC20VestingWallet: access restricted to beneficiary"); + _; + } + + /** + * @dev Set the beneficiary, start timestamp and vesting duration of the vesting wallet. + */ + constructor( + address beneficiaryAddress, + uint256 startTimestamp, + uint256 durationSeconds + ) { + require(beneficiaryAddress != address(0), "ERC20VestingWallet: beneficiary is zero address"); + _beneficiary = beneficiaryAddress; + _start = startTimestamp; + _duration = durationSeconds; + } + + /** + * @dev Getter for the beneficiary address. + */ + function beneficiary() public view virtual returns (address) { + return _beneficiary; + } + + /** + * @dev Getter for the start timestamp. + */ + function start() public view virtual returns (uint256) { + return _start; + } + + /** + * @dev Getter for the vesting duration. + */ + function duration() public view virtual returns (uint256) { + return _duration; + } + + /** + * @dev Delegate the voting right of tokens currently vesting + */ + function delegate(address token, address delegatee) public virtual onlyBeneficiary() { + ERC20Votes(token).delegate(delegatee); + } + + /** + * @dev Amont of token already released + */ + function released(address token) public view returns (uint256) { + return _released[token]; + } + + /** + * @dev Release the tokens that have already vested. + */ + function release(address token) public virtual { + uint256 releasable = vestedAmount(token, block.timestamp) - released(token); + _released[token] += releasable; + emit TokensReleased(token, releasable); + SafeERC20.safeTransfer(IERC20(token), beneficiary(), releasable); + } + + /** + * @dev Calculates the amount that has already vested. Default implementation is a linear vesting curve. + */ + function vestedAmount(address token, uint256 timestamp) public view virtual returns (uint256) { + if (timestamp < start()) { + return 0; + } else if (timestamp >= start() + duration()) { + return _historicalBalance(token); + } else { + return (_historicalBalance(token) * (timestamp - start())) / duration(); + } + } + + /** + * @dev Calculates the historical balance (current balance + already released balance). + */ + function _historicalBalance(address token) internal view virtual returns (uint256) { + return IERC20(token).balanceOf(address(this)) + released(token); + } +} diff --git a/test/finance/VestingWallet.test.js b/test/finance/ERC20VestingWallet.test.js similarity index 91% rename from test/finance/VestingWallet.test.js rename to test/finance/ERC20VestingWallet.test.js index 16e0bcae845..0a705417c20 100644 --- a/test/finance/VestingWallet.test.js +++ b/test/finance/ERC20VestingWallet.test.js @@ -2,11 +2,11 @@ const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/te const { expect } = require('chai'); const ERC20VotesMock = artifacts.require('ERC20VotesMock'); -const VestingWallet = artifacts.require('VestingWallet'); +const ERC20VestingWallet = artifacts.require('ERC20VestingWallet'); const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); -contract('VestingWallet', function (accounts) { +contract('ERC20VestingWallet', function (accounts) { const [ beneficiary, other ] = accounts; const amount = web3.utils.toBN(web3.utils.toWei('100')); @@ -15,7 +15,7 @@ contract('VestingWallet', function (accounts) { beforeEach(async function () { this.start = (await time.latest()).addn(3600); // in 1 hour this.token = await ERC20VotesMock.new('Name', 'Symbol'); - this.vesting = await VestingWallet.new(beneficiary, this.start, duration); + this.vesting = await ERC20VestingWallet.new(beneficiary, this.start, duration); await this.token.mint(this.vesting.address, amount); this.schedule = Array(256).fill() @@ -28,8 +28,8 @@ contract('VestingWallet', function (accounts) { it('rejects zero address for beneficiary', async function () { await expectRevert( - VestingWallet.new(constants.ZERO_ADDRESS, this.start, duration), - 'VestingWallet: beneficiary is zero address', + ERC20VestingWallet.new(constants.ZERO_ADDRESS, this.start, duration), + 'ERC20VestingWallet: beneficiary is zero address', ); }); @@ -91,7 +91,7 @@ contract('VestingWallet', function (accounts) { await expectRevert( this.vesting.delegate(this.token.address, other, { from: other }), - 'VestingWallet: access restricted to beneficiary', + 'ERC20VestingWallet: access restricted to beneficiary', ); expect(await this.token.delegates(this.vesting.address)).to.be.equal(constants.ZERO_ADDRESS); From 217f93688569a196780ff9442dc3db6da272c2e2 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 1 Jul 2021 21:49:29 +0200 Subject: [PATCH 06/22] function ordering consistency --- contracts/finance/ERC20VestingWallet.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/finance/ERC20VestingWallet.sol b/contracts/finance/ERC20VestingWallet.sol index fc208d7a50e..4c31048291e 100644 --- a/contracts/finance/ERC20VestingWallet.sol +++ b/contracts/finance/ERC20VestingWallet.sol @@ -67,17 +67,17 @@ contract ERC20VestingWallet is Context { } /** - * @dev Delegate the voting right of tokens currently vesting + * @dev Amont of token already released */ - function delegate(address token, address delegatee) public virtual onlyBeneficiary() { - ERC20Votes(token).delegate(delegatee); + function released(address token) public view returns (uint256) { + return _released[token]; } /** - * @dev Amont of token already released + * @dev Delegate the voting right of tokens currently vesting */ - function released(address token) public view returns (uint256) { - return _released[token]; + function delegate(address token, address delegatee) public virtual onlyBeneficiary() { + ERC20Votes(token).delegate(delegatee); } /** From f849eb584c39efa17b5a51dfbc16c9a0299ff7ae Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 1 Jul 2021 22:00:51 +0200 Subject: [PATCH 07/22] fix duplicate --- contracts/finance/ERC20VestingWallet.sol | 4 +- contracts/finance/VestingWallet.sol | 114 ----------------------- 2 files changed, 3 insertions(+), 115 deletions(-) delete mode 100644 contracts/finance/VestingWallet.sol diff --git a/contracts/finance/ERC20VestingWallet.sol b/contracts/finance/ERC20VestingWallet.sol index 4c31048291e..b0e2c93ba91 100644 --- a/contracts/finance/ERC20VestingWallet.sol +++ b/contracts/finance/ERC20VestingWallet.sol @@ -9,7 +9,7 @@ import "../utils/Context.sol"; * @title VestingWallet * @dev This contract handles the vesting of ERC20 tokens for a given beneficiary. Custody of multiple tokens can be * given to this contract, which will release the token to the beneficiary following a given vesting schedule. The - * vesting scheduled is customizable through the `vestedAmount(address,uint256)` function. + * vesting schedule is customizable through the {vestedAmount} function. * * Any token transferred to this contract will follow the vesting schedule as if they were locked from the beginning. * Consequently, if the vesting has already started, any amount of tokens sent to this contract will (at least partly) @@ -82,6 +82,8 @@ contract ERC20VestingWallet is Context { /** * @dev Release the tokens that have already vested. + * + * Emits a {TokensReleased} event. */ function release(address token) public virtual { uint256 releasable = vestedAmount(token, block.timestamp) - released(token); diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol deleted file mode 100644 index 17f6da411e4..00000000000 --- a/contracts/finance/VestingWallet.sol +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "../token/ERC20/extensions/ERC20Votes.sol"; -import "../token/ERC20/utils/SafeERC20.sol"; -import "../utils/Context.sol"; - -/** - * @title VestingWallet - * @dev This contract handles the vesting of ERC20 tokens for a given beneficiary. Custody of multiple tokens can be - * given to this contract, which will release the token to the beneficiary following a given vesting schedule. The - * vesting schedule is customizable through the {vestedAmount} function. - * - * Any token transferred to this contract will follow the vesting schedule as if they were locked from the beginning. - * Consequently, if the vesting has already started, any amount of tokens sent to this contract will (at least partly) - * be immediately releasable. - * - * While tokens are locked, the beneficiary still has the ability to delegate the voting power potentially associated - * with these tokens. - */ -contract VestingWallet is Context { - event TokensReleased(address token, uint256 amount); - - mapping(address => uint256) private _released; - address private immutable _beneficiary; - uint256 private immutable _start; - uint256 private immutable _duration; - - modifier onlyBeneficiary() { - require(beneficiary() == _msgSender(), "VestingWallet: access restricted to beneficiary"); - _; - } - - /** - * @dev Set the beneficiary, start timestamp and vesting duration of the vesting wallet. - */ - constructor( - address initialBeneficiary, - uint256 initialStart, - uint256 initialDuration - ) { - require(initialBeneficiary != address(0), "VestingWallet: beneficiary is zero address"); - _beneficiary = initialBeneficiary; - _start = initialStart; - _duration = initialDuration; - } - - /** - * @dev Getter for the beneficiary address. - */ - function beneficiary() public view virtual returns (address) { - return _beneficiary; - } - - /** - * @dev Getter for the start timestamp. - */ - function start() public view virtual returns (uint256) { - return _start; - } - - /** - * @dev Getter for the vesting duration. - */ - function duration() public view virtual returns (uint256) { - return _duration; - } - - /** - * @dev Delegate the voting right of tokens currently vesting - */ - function delegate(address token, address delegatee) public virtual onlyBeneficiary() { - ERC20Votes(token).delegate(delegatee); - } - - /** - * @dev Amount of token already released - */ - function released(address token) public view virtual returns (uint256) { - return _released[token]; - } - - /** - * @dev Release the tokens that have already vested. - * - * Emits a {TokensReleased} event. - */ - function release(address token) public virtual { - uint256 releasable = vestedAmount(token, block.timestamp) - released(token); - _released[token] += releasable; - emit TokensReleased(token, releasable); - SafeERC20.safeTransfer(IERC20(token), beneficiary(), releasable); - } - - /** - * @dev Calculates the amount that has already vested. Default implementation is a linear vesting curve. - */ - function vestedAmount(address token, uint256 timestamp) public view virtual returns (uint256) { - if (timestamp < start()) { - return 0; - } else if (timestamp >= start() + duration()) { - return _historicalBalance(token); - } else { - return (_historicalBalance(token) * (timestamp - start())) / duration(); - } - } - - /** - * @dev Calculates the historical balance (current balance + already released balance). - */ - function _historicalBalance(address token) internal view returns (uint256) { - return IERC20(token).balanceOf(address(this)) + released(token); - } -} From d676c9b85a1a61ecc808478d50ee848ca9534bee Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 6 Jul 2021 09:16:50 +0200 Subject: [PATCH 08/22] split ERC20VestingWallet --- .../finance/vesting/ERC20VestingVoting.sol | 25 ++++++ .../{ => vesting}/ERC20VestingWallet.sol | 17 +--- .../vesting/ERC20VestingWallet.test.js | 87 +++++++++++++++++++ .../ERC20VestingWalletVoting.test.js} | 8 +- 4 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 contracts/finance/vesting/ERC20VestingVoting.sol rename contracts/finance/{ => vesting}/ERC20VestingWallet.sol (86%) create mode 100644 test/finance/vesting/ERC20VestingWallet.test.js rename test/finance/{ERC20VestingWallet.test.js => vesting/ERC20VestingWalletVoting.test.js} (93%) diff --git a/contracts/finance/vesting/ERC20VestingVoting.sol b/contracts/finance/vesting/ERC20VestingVoting.sol new file mode 100644 index 00000000000..8b0f44ce117 --- /dev/null +++ b/contracts/finance/vesting/ERC20VestingVoting.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../../token/ERC20/extensions/ERC20Votes.sol"; +import "./ERC20VestingWallet.sol"; + +/** + * @title ERC20VestingWalletVotes + * @dev This is an extension to {ERC20VestingWallet} that allow the voting with tokens that are locked. The beneficiary can + * delegate the voting power associated with vesting tokens to another wallet. + */ +contract ERC20VestingWalletVoting is ERC20VestingWallet { + constructor( + address beneficiaryAddress, + uint256 startTimestamp, + uint256 durationSeconds + ) ERC20VestingWallet(beneficiaryAddress, startTimestamp, durationSeconds) {} + + /** + * @dev Delegate the voting right of tokens currently vesting + */ + function delegate(address token, address delegatee) public virtual onlyBeneficiary() { + ERC20Votes(token).delegate(delegatee); + } +} diff --git a/contracts/finance/ERC20VestingWallet.sol b/contracts/finance/vesting/ERC20VestingWallet.sol similarity index 86% rename from contracts/finance/ERC20VestingWallet.sol rename to contracts/finance/vesting/ERC20VestingWallet.sol index b0e2c93ba91..23c8aa4c65f 100644 --- a/contracts/finance/ERC20VestingWallet.sol +++ b/contracts/finance/vesting/ERC20VestingWallet.sol @@ -1,12 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "../token/ERC20/extensions/ERC20Votes.sol"; -import "../token/ERC20/utils/SafeERC20.sol"; -import "../utils/Context.sol"; +import "../../token/ERC20/utils/SafeERC20.sol"; +import "../../utils/Context.sol"; /** - * @title VestingWallet + * @title ERC20VestingWallet * @dev This contract handles the vesting of ERC20 tokens for a given beneficiary. Custody of multiple tokens can be * given to this contract, which will release the token to the beneficiary following a given vesting schedule. The * vesting schedule is customizable through the {vestedAmount} function. @@ -14,9 +13,6 @@ import "../utils/Context.sol"; * Any token transferred to this contract will follow the vesting schedule as if they were locked from the beginning. * Consequently, if the vesting has already started, any amount of tokens sent to this contract will (at least partly) * be immediately releasable. - * - * While tokens are locked, the beneficiary still has the ability to delegate the voting power potentially associated - * with these tokens. */ contract ERC20VestingWallet is Context { event TokensReleased(address token, uint256 amount); @@ -73,13 +69,6 @@ contract ERC20VestingWallet is Context { return _released[token]; } - /** - * @dev Delegate the voting right of tokens currently vesting - */ - function delegate(address token, address delegatee) public virtual onlyBeneficiary() { - ERC20Votes(token).delegate(delegatee); - } - /** * @dev Release the tokens that have already vested. * diff --git a/test/finance/vesting/ERC20VestingWallet.test.js b/test/finance/vesting/ERC20VestingWallet.test.js new file mode 100644 index 00000000000..bceed182d57 --- /dev/null +++ b/test/finance/vesting/ERC20VestingWallet.test.js @@ -0,0 +1,87 @@ +const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); +const { expect } = require('chai'); + +const ERC20VotesMock = artifacts.require('ERC20VotesMock'); +const ERC20VestingWallet = artifacts.require('ERC20VestingWallet'); + +const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); + +contract('ERC20VestingWallet', function (accounts) { + const [ beneficiary, other ] = accounts; + + const amount = web3.utils.toBN(web3.utils.toWei('100')); + const duration = web3.utils.toBN(4 * 365 * 86400); // 4 years + + beforeEach(async function () { + this.start = (await time.latest()).addn(3600); // in 1 hour + this.token = await ERC20VotesMock.new('Name', 'Symbol'); + this.vesting = await ERC20VestingWallet.new(beneficiary, this.start, duration); + await this.token.mint(this.vesting.address, amount); + + this.schedule = Array(256).fill() + .map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(this.start)) + .map(timestamp => ({ + timestamp, + vested: min(amount, amount.mul(timestamp.sub(this.start)).div(duration)), + })); + }); + + it('rejects zero address for beneficiary', async function () { + await expectRevert( + ERC20VestingWallet.new(constants.ZERO_ADDRESS, this.start, duration), + 'ERC20VestingWallet: beneficiary is zero address', + ); + }); + + it('check vesting contract', async function () { + expect(await this.vesting.beneficiary()).to.be.equal(beneficiary); + expect(await this.vesting.start()).to.be.bignumber.equal(this.start); + expect(await this.vesting.duration()).to.be.bignumber.equal(duration); + }); + + describe('vesting schedule', function () { + it('check vesting schedule', async function () { + for (const { timestamp, vested } of this.schedule) { + expect(await this.vesting.vestedAmount(this.token.address, timestamp)).to.be.bignumber.equal(vested); + } + }); + + it('execute vesting schedule', async function () { + const { tx } = await this.vesting.release(this.token.address); + await expectEvent.inTransaction(tx, this.vesting, 'TokensReleased', { + token: this.token.address, + amount: '0', + }); + await expectEvent.inTransaction(tx, this.token, 'Transfer', { + from: this.vesting.address, + to: beneficiary, + value: '0', + }); + + // on schedule + let released = web3.utils.toBN(0); + for (const { timestamp, vested } of this.schedule) { + await new Promise(resolve => web3.currentProvider.send({ + method: 'evm_setNextBlockTimestamp', + params: [ timestamp.toNumber() ], + }, resolve)); + + const { tx } = await this.vesting.release(this.token.address); + await expectEvent.inTransaction(tx, this.vesting, 'TokensReleased', { + token: this.token.address, + amount: vested.sub(released), + }); + await expectEvent.inTransaction(tx, this.token, 'Transfer', { + from: this.vesting.address, + to: beneficiary, + value: vested.sub(released), + }); + + released = vested; + + expect(await this.token.balanceOf(this.vesting.address)).to.be.bignumber.equal(amount.sub(vested)); + expect(await this.token.balanceOf(beneficiary)).to.be.bignumber.equal(vested); + } + }); + }); +}); diff --git a/test/finance/ERC20VestingWallet.test.js b/test/finance/vesting/ERC20VestingWalletVoting.test.js similarity index 93% rename from test/finance/ERC20VestingWallet.test.js rename to test/finance/vesting/ERC20VestingWalletVoting.test.js index 0a705417c20..af2e6dc2087 100644 --- a/test/finance/ERC20VestingWallet.test.js +++ b/test/finance/vesting/ERC20VestingWalletVoting.test.js @@ -2,11 +2,11 @@ const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/te const { expect } = require('chai'); const ERC20VotesMock = artifacts.require('ERC20VotesMock'); -const ERC20VestingWallet = artifacts.require('ERC20VestingWallet'); +const ERC20VestingWalletVoting = artifacts.require('ERC20VestingWalletVoting'); const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); -contract('ERC20VestingWallet', function (accounts) { +contract('ERC20VestingWalletVoting', function (accounts) { const [ beneficiary, other ] = accounts; const amount = web3.utils.toBN(web3.utils.toWei('100')); @@ -15,7 +15,7 @@ contract('ERC20VestingWallet', function (accounts) { beforeEach(async function () { this.start = (await time.latest()).addn(3600); // in 1 hour this.token = await ERC20VotesMock.new('Name', 'Symbol'); - this.vesting = await ERC20VestingWallet.new(beneficiary, this.start, duration); + this.vesting = await ERC20VestingWalletVoting.new(beneficiary, this.start, duration); await this.token.mint(this.vesting.address, amount); this.schedule = Array(256).fill() @@ -28,7 +28,7 @@ contract('ERC20VestingWallet', function (accounts) { it('rejects zero address for beneficiary', async function () { await expectRevert( - ERC20VestingWallet.new(constants.ZERO_ADDRESS, this.start, duration), + ERC20VestingWalletVoting.new(constants.ZERO_ADDRESS, this.start, duration), 'ERC20VestingWallet: beneficiary is zero address', ); }); From 6323a51320fba60ed5e2a0564fa2d97f499a0eb2 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 6 Jul 2021 17:05:38 +0200 Subject: [PATCH 09/22] fix lint --- test/finance/vesting/ERC20VestingWallet.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/finance/vesting/ERC20VestingWallet.test.js b/test/finance/vesting/ERC20VestingWallet.test.js index bceed182d57..98fffa7d604 100644 --- a/test/finance/vesting/ERC20VestingWallet.test.js +++ b/test/finance/vesting/ERC20VestingWallet.test.js @@ -7,7 +7,7 @@ const ERC20VestingWallet = artifacts.require('ERC20VestingWallet'); const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); contract('ERC20VestingWallet', function (accounts) { - const [ beneficiary, other ] = accounts; + const [ beneficiary ] = accounts; const amount = web3.utils.toBN(web3.utils.toWei('100')); const duration = web3.utils.toBN(4 * 365 * 86400); // 4 years From f3c29359661464bdb4f41ff577ffb04baae859a2 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 6 Jul 2021 23:19:42 +0200 Subject: [PATCH 10/22] Update contracts/finance/vesting/ERC20VestingVoting.sol Co-authored-by: Francisco Giordano --- contracts/finance/vesting/ERC20VestingVoting.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/finance/vesting/ERC20VestingVoting.sol b/contracts/finance/vesting/ERC20VestingVoting.sol index 8b0f44ce117..33fb0703bf5 100644 --- a/contracts/finance/vesting/ERC20VestingVoting.sol +++ b/contracts/finance/vesting/ERC20VestingVoting.sol @@ -5,7 +5,7 @@ import "../../token/ERC20/extensions/ERC20Votes.sol"; import "./ERC20VestingWallet.sol"; /** - * @title ERC20VestingWalletVotes + * @title ERC20VestingWalletVoting * @dev This is an extension to {ERC20VestingWallet} that allow the voting with tokens that are locked. The beneficiary can * delegate the voting power associated with vesting tokens to another wallet. */ From 82bb43c0d09970c6d54e6f8ac6570b68245861bf Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 8 Jul 2021 16:54:45 +0200 Subject: [PATCH 11/22] use a vestingwallet.behavior.js --- .../vesting/ERC20VestingWallet.test.js | 82 +++++----------- .../vesting/ERC20VestingWalletVoting.test.js | 96 +++++++------------ .../finance/vesting/VestingWallet.behavior.js | 34 +++++++ 3 files changed, 93 insertions(+), 119 deletions(-) create mode 100644 test/finance/vesting/VestingWallet.behavior.js diff --git a/test/finance/vesting/ERC20VestingWallet.test.js b/test/finance/vesting/ERC20VestingWallet.test.js index 98fffa7d604..e96d4d8856f 100644 --- a/test/finance/vesting/ERC20VestingWallet.test.js +++ b/test/finance/vesting/ERC20VestingWallet.test.js @@ -1,6 +1,10 @@ const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); const { expect } = require('chai'); +const { + shouldBehaveLikeVestingWallet, +} = require('./VestingWallet.behavior'); + const ERC20VotesMock = artifacts.require('ERC20VotesMock'); const ERC20VestingWallet = artifacts.require('ERC20VestingWallet'); @@ -15,15 +19,8 @@ contract('ERC20VestingWallet', function (accounts) { beforeEach(async function () { this.start = (await time.latest()).addn(3600); // in 1 hour this.token = await ERC20VotesMock.new('Name', 'Symbol'); - this.vesting = await ERC20VestingWallet.new(beneficiary, this.start, duration); - await this.token.mint(this.vesting.address, amount); - - this.schedule = Array(256).fill() - .map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(this.start)) - .map(timestamp => ({ - timestamp, - vested: min(amount, amount.mul(timestamp.sub(this.start)).div(duration)), - })); + this.mock = await ERC20VestingWallet.new(beneficiary, this.start, duration); + await this.token.mint(this.mock.address, amount); }); it('rejects zero address for beneficiary', async function () { @@ -34,54 +31,27 @@ contract('ERC20VestingWallet', function (accounts) { }); it('check vesting contract', async function () { - expect(await this.vesting.beneficiary()).to.be.equal(beneficiary); - expect(await this.vesting.start()).to.be.bignumber.equal(this.start); - expect(await this.vesting.duration()).to.be.bignumber.equal(duration); + expect(await this.mock.beneficiary()).to.be.equal(beneficiary); + expect(await this.mock.start()).to.be.bignumber.equal(this.start); + expect(await this.mock.duration()).to.be.bignumber.equal(duration); }); - describe('vesting schedule', function () { - it('check vesting schedule', async function () { - for (const { timestamp, vested } of this.schedule) { - expect(await this.vesting.vestedAmount(this.token.address, timestamp)).to.be.bignumber.equal(vested); - } - }); - - it('execute vesting schedule', async function () { - const { tx } = await this.vesting.release(this.token.address); - await expectEvent.inTransaction(tx, this.vesting, 'TokensReleased', { - token: this.token.address, - amount: '0', - }); - await expectEvent.inTransaction(tx, this.token, 'Transfer', { - from: this.vesting.address, + shouldBehaveLikeVestingWallet( + // makeSchedule + (env) => Array(256).fill().map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(env.start)), + // vestingFunction + (env, timestamp) => min(amount, amount.mul(timestamp.sub(env.start)).div(duration)), + // checkRelease + (env, { tx }, amount) => Promise.all([ + expectEvent.inTransaction(tx, env.mock, 'TokensReleased', { + token: env.token.address, + amount, + }), + expectEvent.inTransaction(tx, env.token, 'Transfer', { + from: env.mock.address, to: beneficiary, - value: '0', - }); - - // on schedule - let released = web3.utils.toBN(0); - for (const { timestamp, vested } of this.schedule) { - await new Promise(resolve => web3.currentProvider.send({ - method: 'evm_setNextBlockTimestamp', - params: [ timestamp.toNumber() ], - }, resolve)); - - const { tx } = await this.vesting.release(this.token.address); - await expectEvent.inTransaction(tx, this.vesting, 'TokensReleased', { - token: this.token.address, - amount: vested.sub(released), - }); - await expectEvent.inTransaction(tx, this.token, 'Transfer', { - from: this.vesting.address, - to: beneficiary, - value: vested.sub(released), - }); - - released = vested; - - expect(await this.token.balanceOf(this.vesting.address)).to.be.bignumber.equal(amount.sub(vested)); - expect(await this.token.balanceOf(beneficiary)).to.be.bignumber.equal(vested); - } - }); - }); + value: amount, + }), + ]), + ); }); diff --git a/test/finance/vesting/ERC20VestingWalletVoting.test.js b/test/finance/vesting/ERC20VestingWalletVoting.test.js index af2e6dc2087..8ed87fb89a9 100644 --- a/test/finance/vesting/ERC20VestingWalletVoting.test.js +++ b/test/finance/vesting/ERC20VestingWalletVoting.test.js @@ -1,6 +1,10 @@ const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); const { expect } = require('chai'); +const { + shouldBehaveLikeVestingWallet, +} = require('./VestingWallet.behavior'); + const ERC20VotesMock = artifacts.require('ERC20VotesMock'); const ERC20VestingWalletVoting = artifacts.require('ERC20VestingWalletVoting'); @@ -15,15 +19,8 @@ contract('ERC20VestingWalletVoting', function (accounts) { beforeEach(async function () { this.start = (await time.latest()).addn(3600); // in 1 hour this.token = await ERC20VotesMock.new('Name', 'Symbol'); - this.vesting = await ERC20VestingWalletVoting.new(beneficiary, this.start, duration); - await this.token.mint(this.vesting.address, amount); - - this.schedule = Array(256).fill() - .map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(this.start)) - .map(timestamp => ({ - timestamp, - vested: min(amount, amount.mul(timestamp.sub(this.start)).div(duration)), - })); + this.mock = await ERC20VestingWalletVoting.new(beneficiary, this.start, duration); + await this.token.mint(this.mock.address, amount); }); it('rejects zero address for beneficiary', async function () { @@ -34,75 +31,48 @@ contract('ERC20VestingWalletVoting', function (accounts) { }); it('check vesting contract', async function () { - expect(await this.vesting.beneficiary()).to.be.equal(beneficiary); - expect(await this.vesting.start()).to.be.bignumber.equal(this.start); - expect(await this.vesting.duration()).to.be.bignumber.equal(duration); + expect(await this.mock.beneficiary()).to.be.equal(beneficiary); + expect(await this.mock.start()).to.be.bignumber.equal(this.start); + expect(await this.mock.duration()).to.be.bignumber.equal(duration); }); - describe('vesting schedule', function () { - it('check vesting schedule', async function () { - for (const { timestamp, vested } of this.schedule) { - expect(await this.vesting.vestedAmount(this.token.address, timestamp)).to.be.bignumber.equal(vested); - } - }); - - it('execute vesting schedule', async function () { - const { tx } = await this.vesting.release(this.token.address); - await expectEvent.inTransaction(tx, this.vesting, 'TokensReleased', { - token: this.token.address, - amount: '0', - }); - await expectEvent.inTransaction(tx, this.token, 'Transfer', { - from: this.vesting.address, + shouldBehaveLikeVestingWallet( + // makeSchedule + (env) => Array(256).fill().map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(env.start)), + // vestingFunction + (env, timestamp) => min(amount, amount.mul(timestamp.sub(env.start)).div(duration)), + // checkRelease + (env, { tx }, amount) => Promise.all([ + expectEvent.inTransaction(tx, env.mock, 'TokensReleased', { + token: env.token.address, + amount, + }), + expectEvent.inTransaction(tx, env.token, 'Transfer', { + from: env.mock.address, to: beneficiary, - value: '0', - }); - - // on schedule - let released = web3.utils.toBN(0); - for (const { timestamp, vested } of this.schedule) { - await new Promise(resolve => web3.currentProvider.send({ - method: 'evm_setNextBlockTimestamp', - params: [ timestamp.toNumber() ], - }, resolve)); - - const { tx } = await this.vesting.release(this.token.address); - await expectEvent.inTransaction(tx, this.vesting, 'TokensReleased', { - token: this.token.address, - amount: vested.sub(released), - }); - await expectEvent.inTransaction(tx, this.token, 'Transfer', { - from: this.vesting.address, - to: beneficiary, - value: vested.sub(released), - }); - - released = vested; - - expect(await this.token.balanceOf(this.vesting.address)).to.be.bignumber.equal(amount.sub(vested)); - expect(await this.token.balanceOf(beneficiary)).to.be.bignumber.equal(vested); - } - }); - }); + value: amount, + }), + ]), + ); describe('delegate vote', function () { it('wrong caller', async function () { - expect(await this.token.delegates(this.vesting.address)).to.be.equal(constants.ZERO_ADDRESS); + expect(await this.token.delegates(this.mock.address)).to.be.equal(constants.ZERO_ADDRESS); await expectRevert( - this.vesting.delegate(this.token.address, other, { from: other }), + this.mock.delegate(this.token.address, other, { from: other }), 'ERC20VestingWallet: access restricted to beneficiary', ); - expect(await this.token.delegates(this.vesting.address)).to.be.equal(constants.ZERO_ADDRESS); + expect(await this.token.delegates(this.mock.address)).to.be.equal(constants.ZERO_ADDRESS); }); it('authorized call', async function () { - expect(await this.token.delegates(this.vesting.address)).to.be.equal(constants.ZERO_ADDRESS); + expect(await this.token.delegates(this.mock.address)).to.be.equal(constants.ZERO_ADDRESS); - const { tx } = await this.vesting.delegate(this.token.address, other, { from: beneficiary }); + const { tx } = await this.mock.delegate(this.token.address, other, { from: beneficiary }); await expectEvent.inTransaction(tx, this.token, 'DelegateChanged', { - delegator: this.vesting.address, + delegator: this.mock.address, fromDelegate: constants.ZERO_ADDRESS, toDelegate: other, }); @@ -112,7 +82,7 @@ contract('ERC20VestingWalletVoting', function (accounts) { newBalance: amount, }); - expect(await this.token.delegates(this.vesting.address)).to.be.equal(other); + expect(await this.token.delegates(this.mock.address)).to.be.equal(other); }); }); }); diff --git a/test/finance/vesting/VestingWallet.behavior.js b/test/finance/vesting/VestingWallet.behavior.js new file mode 100644 index 00000000000..90e0ed71dca --- /dev/null +++ b/test/finance/vesting/VestingWallet.behavior.js @@ -0,0 +1,34 @@ +const { expect } = require('chai'); + +function shouldBehaveLikeVestingWallet (makeSchedule, vestingFunction, checkRelease) { + describe('vesting schedule', function () { + it('check vesting schedule', async function () { + for (const timestamp of await makeSchedule(this)) { + expect(await this.mock.vestedAmount(this.token.address, timestamp)) + .to.be.bignumber.equal(await vestingFunction(this, timestamp)); + } + }); + + it('execute vesting schedule', async function () { + const receipt = await this.mock.release(this.token.address); + checkRelease(this, receipt, '0'); + + let released = web3.utils.toBN(0); + for (const timestamp of await makeSchedule(this)) { + const vested = await vestingFunction(this, timestamp); + await new Promise(resolve => web3.currentProvider.send({ + method: 'evm_setNextBlockTimestamp', + params: [ timestamp.toNumber() ], + }, resolve)); + + const receipt = await this.mock.release(this.token.address); + checkRelease(this, receipt, vested.sub(released)); + released = vested; + } + }); + }); +} + +module.exports = { + shouldBehaveLikeVestingWallet, +}; From bb8393f191eb450c2e1f1ce030e01c12afe56e32 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 26 Jul 2021 17:55:56 +0200 Subject: [PATCH 12/22] fix file naming --- .../{ERC20VestingVoting.sol => ERC20VestingWalletVoting.sol} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename contracts/finance/vesting/{ERC20VestingVoting.sol => ERC20VestingWalletVoting.sol} (100%) diff --git a/contracts/finance/vesting/ERC20VestingVoting.sol b/contracts/finance/vesting/ERC20VestingWalletVoting.sol similarity index 100% rename from contracts/finance/vesting/ERC20VestingVoting.sol rename to contracts/finance/vesting/ERC20VestingWalletVoting.sol From 35e352e9b3a2e1be9ade7169742a861d25a62c48 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 2 Sep 2021 18:20:31 +0200 Subject: [PATCH 13/22] add support for ERC20 and ETH in the vesting wallet --- .../vesting/ERC20VestingWalletVoting.sol | 25 --- ...C20VestingWallet.sol => VestingWallet.sol} | 37 +++-- .../vesting/ERC20VestingWallet.test.js | 57 ------- .../vesting/ERC20VestingWalletVoting.test.js | 88 ----------- .../finance/vesting/VestingWallet.behavior.js | 34 ---- test/finance/vesting/VestingWallet.test.js | 146 ++++++++++++++++++ 6 files changed, 174 insertions(+), 213 deletions(-) delete mode 100644 contracts/finance/vesting/ERC20VestingWalletVoting.sol rename contracts/finance/vesting/{ERC20VestingWallet.sol => VestingWallet.sol} (68%) delete mode 100644 test/finance/vesting/ERC20VestingWallet.test.js delete mode 100644 test/finance/vesting/ERC20VestingWalletVoting.test.js delete mode 100644 test/finance/vesting/VestingWallet.behavior.js create mode 100644 test/finance/vesting/VestingWallet.test.js diff --git a/contracts/finance/vesting/ERC20VestingWalletVoting.sol b/contracts/finance/vesting/ERC20VestingWalletVoting.sol deleted file mode 100644 index 33fb0703bf5..00000000000 --- a/contracts/finance/vesting/ERC20VestingWalletVoting.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "../../token/ERC20/extensions/ERC20Votes.sol"; -import "./ERC20VestingWallet.sol"; - -/** - * @title ERC20VestingWalletVoting - * @dev This is an extension to {ERC20VestingWallet} that allow the voting with tokens that are locked. The beneficiary can - * delegate the voting power associated with vesting tokens to another wallet. - */ -contract ERC20VestingWalletVoting is ERC20VestingWallet { - constructor( - address beneficiaryAddress, - uint256 startTimestamp, - uint256 durationSeconds - ) ERC20VestingWallet(beneficiaryAddress, startTimestamp, durationSeconds) {} - - /** - * @dev Delegate the voting right of tokens currently vesting - */ - function delegate(address token, address delegatee) public virtual onlyBeneficiary() { - ERC20Votes(token).delegate(delegatee); - } -} diff --git a/contracts/finance/vesting/ERC20VestingWallet.sol b/contracts/finance/vesting/VestingWallet.sol similarity index 68% rename from contracts/finance/vesting/ERC20VestingWallet.sol rename to contracts/finance/vesting/VestingWallet.sol index 23c8aa4c65f..ed8237fbf2a 100644 --- a/contracts/finance/vesting/ERC20VestingWallet.sol +++ b/contracts/finance/vesting/VestingWallet.sol @@ -2,19 +2,20 @@ pragma solidity ^0.8.0; import "../../token/ERC20/utils/SafeERC20.sol"; +import "../../utils/Address.sol"; import "../../utils/Context.sol"; /** - * @title ERC20VestingWallet - * @dev This contract handles the vesting of ERC20 tokens for a given beneficiary. Custody of multiple tokens can be - * given to this contract, which will release the token to the beneficiary following a given vesting schedule. The - * vesting schedule is customizable through the {vestedAmount} function. + * @title VestingWallet + * @dev This contract handles the vesting of Eth and ERC20 tokens for a given beneficiary. Custody of multiple tokens + * can be given to this contract, which will release the token to the beneficiary following a given vesting schedule. + * The vesting schedule is customizable through the {vestedAmount} function. * * Any token transferred to this contract will follow the vesting schedule as if they were locked from the beginning. * Consequently, if the vesting has already started, any amount of tokens sent to this contract will (at least partly) * be immediately releasable. */ -contract ERC20VestingWallet is Context { +contract VestingWallet is Context { event TokensReleased(address token, uint256 amount); mapping(address => uint256) private _released; @@ -23,7 +24,7 @@ contract ERC20VestingWallet is Context { uint256 private immutable _duration; modifier onlyBeneficiary() { - require(beneficiary() == _msgSender(), "ERC20VestingWallet: access restricted to beneficiary"); + require(beneficiary() == _msgSender(), "VestingWallet: access restricted to beneficiary"); _; } @@ -35,12 +36,17 @@ contract ERC20VestingWallet is Context { uint256 startTimestamp, uint256 durationSeconds ) { - require(beneficiaryAddress != address(0), "ERC20VestingWallet: beneficiary is zero address"); + require(beneficiaryAddress != address(0), "VestingWallet: beneficiary is zero address"); _beneficiary = beneficiaryAddress; _start = startTimestamp; _duration = durationSeconds; } + /** + * @dev The contract should be able to receive Eth. + */ + receive() external payable {} + /** * @dev Getter for the beneficiary address. */ @@ -78,7 +84,20 @@ contract ERC20VestingWallet is Context { uint256 releasable = vestedAmount(token, block.timestamp) - released(token); _released[token] += releasable; emit TokensReleased(token, releasable); - SafeERC20.safeTransfer(IERC20(token), beneficiary(), releasable); + if (token == address(0)) { + Address.sendValue(payable(beneficiary()), releasable); + } else { + SafeERC20.safeTransfer(IERC20(token), beneficiary(), releasable); + } + } + + /** + * @dev Release the native token (ether) that have already vested. + * + * Emits a {TokensReleased} event. + */ + function release() public virtual { + release(address(0)); } /** @@ -98,6 +117,6 @@ contract ERC20VestingWallet is Context { * @dev Calculates the historical balance (current balance + already released balance). */ function _historicalBalance(address token) internal view virtual returns (uint256) { - return IERC20(token).balanceOf(address(this)) + released(token); + return (token == address(0) ? address(this).balance : IERC20(token).balanceOf(address(this))) + released(token); } } diff --git a/test/finance/vesting/ERC20VestingWallet.test.js b/test/finance/vesting/ERC20VestingWallet.test.js deleted file mode 100644 index e96d4d8856f..00000000000 --- a/test/finance/vesting/ERC20VestingWallet.test.js +++ /dev/null @@ -1,57 +0,0 @@ -const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); -const { expect } = require('chai'); - -const { - shouldBehaveLikeVestingWallet, -} = require('./VestingWallet.behavior'); - -const ERC20VotesMock = artifacts.require('ERC20VotesMock'); -const ERC20VestingWallet = artifacts.require('ERC20VestingWallet'); - -const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); - -contract('ERC20VestingWallet', function (accounts) { - const [ beneficiary ] = accounts; - - const amount = web3.utils.toBN(web3.utils.toWei('100')); - const duration = web3.utils.toBN(4 * 365 * 86400); // 4 years - - beforeEach(async function () { - this.start = (await time.latest()).addn(3600); // in 1 hour - this.token = await ERC20VotesMock.new('Name', 'Symbol'); - this.mock = await ERC20VestingWallet.new(beneficiary, this.start, duration); - await this.token.mint(this.mock.address, amount); - }); - - it('rejects zero address for beneficiary', async function () { - await expectRevert( - ERC20VestingWallet.new(constants.ZERO_ADDRESS, this.start, duration), - 'ERC20VestingWallet: beneficiary is zero address', - ); - }); - - it('check vesting contract', async function () { - expect(await this.mock.beneficiary()).to.be.equal(beneficiary); - expect(await this.mock.start()).to.be.bignumber.equal(this.start); - expect(await this.mock.duration()).to.be.bignumber.equal(duration); - }); - - shouldBehaveLikeVestingWallet( - // makeSchedule - (env) => Array(256).fill().map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(env.start)), - // vestingFunction - (env, timestamp) => min(amount, amount.mul(timestamp.sub(env.start)).div(duration)), - // checkRelease - (env, { tx }, amount) => Promise.all([ - expectEvent.inTransaction(tx, env.mock, 'TokensReleased', { - token: env.token.address, - amount, - }), - expectEvent.inTransaction(tx, env.token, 'Transfer', { - from: env.mock.address, - to: beneficiary, - value: amount, - }), - ]), - ); -}); diff --git a/test/finance/vesting/ERC20VestingWalletVoting.test.js b/test/finance/vesting/ERC20VestingWalletVoting.test.js deleted file mode 100644 index 8ed87fb89a9..00000000000 --- a/test/finance/vesting/ERC20VestingWalletVoting.test.js +++ /dev/null @@ -1,88 +0,0 @@ -const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); -const { expect } = require('chai'); - -const { - shouldBehaveLikeVestingWallet, -} = require('./VestingWallet.behavior'); - -const ERC20VotesMock = artifacts.require('ERC20VotesMock'); -const ERC20VestingWalletVoting = artifacts.require('ERC20VestingWalletVoting'); - -const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); - -contract('ERC20VestingWalletVoting', function (accounts) { - const [ beneficiary, other ] = accounts; - - const amount = web3.utils.toBN(web3.utils.toWei('100')); - const duration = web3.utils.toBN(4 * 365 * 86400); // 4 years - - beforeEach(async function () { - this.start = (await time.latest()).addn(3600); // in 1 hour - this.token = await ERC20VotesMock.new('Name', 'Symbol'); - this.mock = await ERC20VestingWalletVoting.new(beneficiary, this.start, duration); - await this.token.mint(this.mock.address, amount); - }); - - it('rejects zero address for beneficiary', async function () { - await expectRevert( - ERC20VestingWalletVoting.new(constants.ZERO_ADDRESS, this.start, duration), - 'ERC20VestingWallet: beneficiary is zero address', - ); - }); - - it('check vesting contract', async function () { - expect(await this.mock.beneficiary()).to.be.equal(beneficiary); - expect(await this.mock.start()).to.be.bignumber.equal(this.start); - expect(await this.mock.duration()).to.be.bignumber.equal(duration); - }); - - shouldBehaveLikeVestingWallet( - // makeSchedule - (env) => Array(256).fill().map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(env.start)), - // vestingFunction - (env, timestamp) => min(amount, amount.mul(timestamp.sub(env.start)).div(duration)), - // checkRelease - (env, { tx }, amount) => Promise.all([ - expectEvent.inTransaction(tx, env.mock, 'TokensReleased', { - token: env.token.address, - amount, - }), - expectEvent.inTransaction(tx, env.token, 'Transfer', { - from: env.mock.address, - to: beneficiary, - value: amount, - }), - ]), - ); - - describe('delegate vote', function () { - it('wrong caller', async function () { - expect(await this.token.delegates(this.mock.address)).to.be.equal(constants.ZERO_ADDRESS); - - await expectRevert( - this.mock.delegate(this.token.address, other, { from: other }), - 'ERC20VestingWallet: access restricted to beneficiary', - ); - - expect(await this.token.delegates(this.mock.address)).to.be.equal(constants.ZERO_ADDRESS); - }); - - it('authorized call', async function () { - expect(await this.token.delegates(this.mock.address)).to.be.equal(constants.ZERO_ADDRESS); - - const { tx } = await this.mock.delegate(this.token.address, other, { from: beneficiary }); - await expectEvent.inTransaction(tx, this.token, 'DelegateChanged', { - delegator: this.mock.address, - fromDelegate: constants.ZERO_ADDRESS, - toDelegate: other, - }); - await expectEvent.inTransaction(tx, this.token, 'DelegateVotesChanged', { - delegate: other, - previousBalance: '0', - newBalance: amount, - }); - - expect(await this.token.delegates(this.mock.address)).to.be.equal(other); - }); - }); -}); diff --git a/test/finance/vesting/VestingWallet.behavior.js b/test/finance/vesting/VestingWallet.behavior.js deleted file mode 100644 index 90e0ed71dca..00000000000 --- a/test/finance/vesting/VestingWallet.behavior.js +++ /dev/null @@ -1,34 +0,0 @@ -const { expect } = require('chai'); - -function shouldBehaveLikeVestingWallet (makeSchedule, vestingFunction, checkRelease) { - describe('vesting schedule', function () { - it('check vesting schedule', async function () { - for (const timestamp of await makeSchedule(this)) { - expect(await this.mock.vestedAmount(this.token.address, timestamp)) - .to.be.bignumber.equal(await vestingFunction(this, timestamp)); - } - }); - - it('execute vesting schedule', async function () { - const receipt = await this.mock.release(this.token.address); - checkRelease(this, receipt, '0'); - - let released = web3.utils.toBN(0); - for (const timestamp of await makeSchedule(this)) { - const vested = await vestingFunction(this, timestamp); - await new Promise(resolve => web3.currentProvider.send({ - method: 'evm_setNextBlockTimestamp', - params: [ timestamp.toNumber() ], - }, resolve)); - - const receipt = await this.mock.release(this.token.address); - checkRelease(this, receipt, vested.sub(released)); - released = vested; - } - }); - }); -} - -module.exports = { - shouldBehaveLikeVestingWallet, -}; diff --git a/test/finance/vesting/VestingWallet.test.js b/test/finance/vesting/VestingWallet.test.js new file mode 100644 index 00000000000..6c66038ac73 --- /dev/null +++ b/test/finance/vesting/VestingWallet.test.js @@ -0,0 +1,146 @@ +const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); +const { web3 } = require('@openzeppelin/test-helpers/src/setup'); +const { expect } = require('chai'); + +const ERC20Mock = artifacts.require('ERC20Mock'); +const VestingWallet = artifacts.require('VestingWallet'); + +const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); + +contract('VestingWallet', function (accounts) { + const [ sender, beneficiary ] = accounts; + + const amount = web3.utils.toBN(web3.utils.toWei('100')); + const duration = web3.utils.toBN(4 * 365 * 86400); // 4 years + + beforeEach(async function () { + this.start = (await time.latest()).addn(3600); // in 1 hour + this.mock = await VestingWallet.new(beneficiary, this.start, duration); + }); + + it('rejects zero address for beneficiary', async function () { + await expectRevert( + VestingWallet.new(constants.ZERO_ADDRESS, this.start, duration), + 'VestingWallet: beneficiary is zero address', + ); + }); + + it('check vesting contract', async function () { + expect(await this.mock.beneficiary()).to.be.equal(beneficiary); + expect(await this.mock.start()).to.be.bignumber.equal(this.start); + expect(await this.mock.duration()).to.be.bignumber.equal(duration); + }); + + describe('vesting schedule', function () { + beforeEach(async function () { + this.schedule = Array(256).fill().map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(this.start)); + this.vestingFn = timestamp => min(amount, amount.mul(timestamp.sub(this.start)).div(duration)); + }); + + describe('Eth vesting', function () { + beforeEach(async function () { + await web3.eth.sendTransaction({ from: sender, to: this.mock.address, value: amount }); + }); + + it('check vesting schedule', async function () { + for (const timestamp of this.schedule) { + expect(await this.mock.vestedAmount(constants.ZERO_ADDRESS, timestamp)) + .to.be.bignumber.equal(this.vestingFn(timestamp)); + } + }); + + it('execute vesting schedule', async function () { + let released = web3.utils.toBN(0); + const balanceBefore = await web3.eth.getBalance(beneficiary).then(web3.utils.toBN); + + { + const receipt = await this.mock.release(); + + await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { + token: constants.ZERO_ADDRESS, + amount: '0', + }); + + expect(await web3.eth.getBalance(beneficiary).then(web3.utils.toBN)).to.be.bignumber.equal(balanceBefore); + } + + + for (const timestamp of this.schedule) { + const vested = this.vestingFn(timestamp); + + await new Promise(resolve => web3.currentProvider.send({ + method: 'evm_setNextBlockTimestamp', + params: [ timestamp.toNumber() ], + }, resolve)); + + const receipt = await this.mock.release(); + + await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { + token: constants.ZERO_ADDRESS, + amount: vested.sub(released), + }); + + expect(await web3.eth.getBalance(beneficiary).then(web3.utils.toBN)).to.be.bignumber.equal(balanceBefore.add(vested)); + + released = vested; + } + }); + }); + + describe('ERC20 vesting', function () { + beforeEach(async function () { + this.token = await ERC20Mock.new('Name', 'Symbol', this.mock.address, amount); + }); + + it('check vesting schedule', async function () { + for (const timestamp of this.schedule) { + expect(await this.mock.vestedAmount(this.token.address, timestamp)) + .to.be.bignumber.equal(this.vestingFn(timestamp)); + } + }); + + it('execute vesting schedule', async function () { + let released = web3.utils.toBN(0); + + { + const receipt = await this.mock.release(this.token.address); + + await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { + token: this.token.address, + amount: '0', + }); + + await expectEvent.inTransaction(receipt.tx, this.token, 'Transfer', { + from: this.mock.address, + to: beneficiary, + value: '0', + }); + } + + for (const timestamp of this.schedule) { + const vested = this.vestingFn(timestamp); + + await new Promise(resolve => web3.currentProvider.send({ + method: 'evm_setNextBlockTimestamp', + params: [ timestamp.toNumber() ], + }, resolve)); + + const receipt = await this.mock.release(this.token.address); + + await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { + token: this.token.address, + amount: vested.sub(released), + }); + + await expectEvent.inTransaction(receipt.tx, this.token, 'Transfer', { + from: this.mock.address, + to: beneficiary, + value: vested.sub(released), + }); + + released = vested; + } + }); + }); + }); +}); From 7be79ce3a68ad650204fe24c1c734ab3554813d7 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 3 Sep 2021 00:40:19 +0200 Subject: [PATCH 14/22] remove finance/vesting subfolder and refactor tests --- .../finance/{vesting => }/VestingWallet.sol | 15 +- test/finance/VestingWallet.behavior.js | 60 +++++++ test/finance/VestingWallet.test.js | 67 ++++++++ test/finance/vesting/VestingWallet.test.js | 146 ------------------ 4 files changed, 138 insertions(+), 150 deletions(-) rename contracts/finance/{vesting => }/VestingWallet.sol (88%) create mode 100644 test/finance/VestingWallet.behavior.js create mode 100644 test/finance/VestingWallet.test.js delete mode 100644 test/finance/vesting/VestingWallet.test.js diff --git a/contracts/finance/vesting/VestingWallet.sol b/contracts/finance/VestingWallet.sol similarity index 88% rename from contracts/finance/vesting/VestingWallet.sol rename to contracts/finance/VestingWallet.sol index ed8237fbf2a..f5b6223692a 100644 --- a/contracts/finance/vesting/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "../../token/ERC20/utils/SafeERC20.sol"; -import "../../utils/Address.sol"; -import "../../utils/Context.sol"; +import "../token/ERC20/utils/SafeERC20.sol"; +import "../utils/Address.sol"; +import "../utils/Context.sol"; /** * @title VestingWallet @@ -101,7 +101,7 @@ contract VestingWallet is Context { } /** - * @dev Calculates the amount that has already vested. Default implementation is a linear vesting curve. + * @dev Calculates the amount of tokens that has already vested. Default implementation is a linear vesting curve. */ function vestedAmount(address token, uint256 timestamp) public view virtual returns (uint256) { if (timestamp < start()) { @@ -113,6 +113,13 @@ contract VestingWallet is Context { } } + /** + * @dev Calculates the amount of ether that has already vested. Default implementation is a linear vesting curve. + */ + function vestedAmount(uint256 timestamp) public view virtual returns (uint256) { + return vestedAmount(address(0), timestamp); + } + /** * @dev Calculates the historical balance (current balance + already released balance). */ diff --git a/test/finance/VestingWallet.behavior.js b/test/finance/VestingWallet.behavior.js new file mode 100644 index 00000000000..2b66dd04bbc --- /dev/null +++ b/test/finance/VestingWallet.behavior.js @@ -0,0 +1,60 @@ +const { constants, expectEvent } = require('@openzeppelin/test-helpers'); +const { expect } = require('chai'); + +function shouldBehaveLikeVesting (beneficiary) { + it('check vesting schedule', async function () { + const args = this.token ? [ this.token.address ] : []; + + for (const timestamp of this.schedule) { + expect(await this.mock.vestedAmount(...args, timestamp)) + .to.be.bignumber.equal(this.vestingFn(timestamp)); + } + }); + + it('execute vesting schedule', async function () { + const args = this.token ? [ this.token.address ] : []; + + let released = web3.utils.toBN(0); + const before = await this.getBalance(beneficiary); + + { + const receipt = await this.mock.release(...args); + + await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { + token: this.token ? this.token.address : constants.ZERO_ADDRESS, + amount: '0', + }); + + await this.checkRelease(receipt, beneficiary, '0'); + + expect(await this.getBalance(beneficiary)).to.be.bignumber.equal(before); + } + + for (const timestamp of this.schedule) { + const vested = this.vestingFn(timestamp); + + await new Promise(resolve => web3.currentProvider.send({ + method: 'evm_setNextBlockTimestamp', + params: [ timestamp.toNumber() ], + }, resolve)); + + const receipt = await this.mock.release(...args); + + await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { + token: this.token ? this.token.address : constants.ZERO_ADDRESS, + amount: vested.sub(released), + }); + + await this.checkRelease(receipt, beneficiary, vested.sub(released)); + + expect(await this.getBalance(beneficiary)) + .to.be.bignumber.equal(before.add(vested)); + + released = vested; + } + }); +} + +module.exports = { + shouldBehaveLikeVesting, +}; diff --git a/test/finance/VestingWallet.test.js b/test/finance/VestingWallet.test.js new file mode 100644 index 00000000000..6aa73780531 --- /dev/null +++ b/test/finance/VestingWallet.test.js @@ -0,0 +1,67 @@ +const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); +const { web3 } = require('@openzeppelin/test-helpers/src/setup'); +const { expect } = require('chai'); + +const ERC20Mock = artifacts.require('ERC20Mock'); +const VestingWallet = artifacts.require('VestingWallet'); + +const { shouldBehaveLikeVesting } = require('./VestingWallet.behavior'); + +const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); + +contract('VestingWallet', function (accounts) { + const [ sender, beneficiary ] = accounts; + + const amount = web3.utils.toBN(web3.utils.toWei('100')); + const duration = web3.utils.toBN(4 * 365 * 86400); // 4 years + + beforeEach(async function () { + this.start = (await time.latest()).addn(3600); // in 1 hour + this.mock = await VestingWallet.new(beneficiary, this.start, duration); + }); + + it('rejects zero address for beneficiary', async function () { + await expectRevert( + VestingWallet.new(constants.ZERO_ADDRESS, this.start, duration), + 'VestingWallet: beneficiary is zero address', + ); + }); + + it('check vesting contract', async function () { + expect(await this.mock.beneficiary()).to.be.equal(beneficiary); + expect(await this.mock.start()).to.be.bignumber.equal(this.start); + expect(await this.mock.duration()).to.be.bignumber.equal(duration); + }); + + describe('vesting schedule', function () { + beforeEach(async function () { + this.schedule = Array(64).fill().map((_, i) => web3.utils.toBN(i).mul(duration).divn(60).add(this.start)); + this.vestingFn = timestamp => min(amount, amount.mul(timestamp.sub(this.start)).div(duration)); + }); + + describe('Eth vesting', function () { + beforeEach(async function () { + await web3.eth.sendTransaction({ from: sender, to: this.mock.address, value: amount }); + this.getBalance = account => web3.eth.getBalance(account).then(web3.utils.toBN); + this.checkRelease = () => {}; + }); + + shouldBehaveLikeVesting(beneficiary); + }); + + describe('ERC20 vesting', function () { + beforeEach(async function () { + this.token = await ERC20Mock.new('Name', 'Symbol', this.mock.address, amount); + this.getBalance = (account) => this.token.balanceOf(account); + this.checkRelease = (receipt, to, value) => expectEvent.inTransaction( + receipt.tx, + this.token, + 'Transfer', + { from: this.mock.address, to, value }, + ); + }); + + shouldBehaveLikeVesting(beneficiary); + }); + }); +}); diff --git a/test/finance/vesting/VestingWallet.test.js b/test/finance/vesting/VestingWallet.test.js deleted file mode 100644 index 6c66038ac73..00000000000 --- a/test/finance/vesting/VestingWallet.test.js +++ /dev/null @@ -1,146 +0,0 @@ -const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); -const { web3 } = require('@openzeppelin/test-helpers/src/setup'); -const { expect } = require('chai'); - -const ERC20Mock = artifacts.require('ERC20Mock'); -const VestingWallet = artifacts.require('VestingWallet'); - -const min = (...args) => args.slice(1).reduce((x, y) => x.lt(y) ? x : y, args[0]); - -contract('VestingWallet', function (accounts) { - const [ sender, beneficiary ] = accounts; - - const amount = web3.utils.toBN(web3.utils.toWei('100')); - const duration = web3.utils.toBN(4 * 365 * 86400); // 4 years - - beforeEach(async function () { - this.start = (await time.latest()).addn(3600); // in 1 hour - this.mock = await VestingWallet.new(beneficiary, this.start, duration); - }); - - it('rejects zero address for beneficiary', async function () { - await expectRevert( - VestingWallet.new(constants.ZERO_ADDRESS, this.start, duration), - 'VestingWallet: beneficiary is zero address', - ); - }); - - it('check vesting contract', async function () { - expect(await this.mock.beneficiary()).to.be.equal(beneficiary); - expect(await this.mock.start()).to.be.bignumber.equal(this.start); - expect(await this.mock.duration()).to.be.bignumber.equal(duration); - }); - - describe('vesting schedule', function () { - beforeEach(async function () { - this.schedule = Array(256).fill().map((_, i) => web3.utils.toBN(i).mul(duration).divn(224).add(this.start)); - this.vestingFn = timestamp => min(amount, amount.mul(timestamp.sub(this.start)).div(duration)); - }); - - describe('Eth vesting', function () { - beforeEach(async function () { - await web3.eth.sendTransaction({ from: sender, to: this.mock.address, value: amount }); - }); - - it('check vesting schedule', async function () { - for (const timestamp of this.schedule) { - expect(await this.mock.vestedAmount(constants.ZERO_ADDRESS, timestamp)) - .to.be.bignumber.equal(this.vestingFn(timestamp)); - } - }); - - it('execute vesting schedule', async function () { - let released = web3.utils.toBN(0); - const balanceBefore = await web3.eth.getBalance(beneficiary).then(web3.utils.toBN); - - { - const receipt = await this.mock.release(); - - await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { - token: constants.ZERO_ADDRESS, - amount: '0', - }); - - expect(await web3.eth.getBalance(beneficiary).then(web3.utils.toBN)).to.be.bignumber.equal(balanceBefore); - } - - - for (const timestamp of this.schedule) { - const vested = this.vestingFn(timestamp); - - await new Promise(resolve => web3.currentProvider.send({ - method: 'evm_setNextBlockTimestamp', - params: [ timestamp.toNumber() ], - }, resolve)); - - const receipt = await this.mock.release(); - - await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { - token: constants.ZERO_ADDRESS, - amount: vested.sub(released), - }); - - expect(await web3.eth.getBalance(beneficiary).then(web3.utils.toBN)).to.be.bignumber.equal(balanceBefore.add(vested)); - - released = vested; - } - }); - }); - - describe('ERC20 vesting', function () { - beforeEach(async function () { - this.token = await ERC20Mock.new('Name', 'Symbol', this.mock.address, amount); - }); - - it('check vesting schedule', async function () { - for (const timestamp of this.schedule) { - expect(await this.mock.vestedAmount(this.token.address, timestamp)) - .to.be.bignumber.equal(this.vestingFn(timestamp)); - } - }); - - it('execute vesting schedule', async function () { - let released = web3.utils.toBN(0); - - { - const receipt = await this.mock.release(this.token.address); - - await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { - token: this.token.address, - amount: '0', - }); - - await expectEvent.inTransaction(receipt.tx, this.token, 'Transfer', { - from: this.mock.address, - to: beneficiary, - value: '0', - }); - } - - for (const timestamp of this.schedule) { - const vested = this.vestingFn(timestamp); - - await new Promise(resolve => web3.currentProvider.send({ - method: 'evm_setNextBlockTimestamp', - params: [ timestamp.toNumber() ], - }, resolve)); - - const receipt = await this.mock.release(this.token.address); - - await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { - token: this.token.address, - amount: vested.sub(released), - }); - - await expectEvent.inTransaction(receipt.tx, this.token, 'Transfer', { - from: this.mock.address, - to: beneficiary, - value: vested.sub(released), - }); - - released = vested; - } - }); - }); - }); -}); From b74230f17951866bd240f12bbcf194ca8f23c74a Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 6 Oct 2021 18:53:51 +0200 Subject: [PATCH 15/22] split release mechanism for ERC20 and native token --- contracts/finance/VestingWallet.sol | 68 ++++++++++++++------------ test/finance/VestingWallet.behavior.js | 26 +++++++--- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index f5b6223692a..56ede846180 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import "../token/ERC20/utils/SafeERC20.sol"; import "../utils/Address.sol"; import "../utils/Context.sol"; +import "../utils/math/Math.sol"; /** * @title VestingWallet @@ -16,9 +17,11 @@ import "../utils/Context.sol"; * be immediately releasable. */ contract VestingWallet is Context { - event TokensReleased(address token, uint256 amount); + event TokensReleased(uint256 amount); + event ERC20TokensReleased(address token, uint256 amount); - mapping(address => uint256) private _released; + uint256 private _released; + mapping(address => uint256) private _erc20Released; address private immutable _beneficiary; uint256 private immutable _start; uint256 private immutable _duration; @@ -69,26 +72,17 @@ contract VestingWallet is Context { } /** - * @dev Amont of token already released + * @dev Amont of eth already released */ - function released(address token) public view returns (uint256) { - return _released[token]; + function released() public view returns (uint256) { + return _released; } /** - * @dev Release the tokens that have already vested. - * - * Emits a {TokensReleased} event. + * @dev Amont of token already released */ - function release(address token) public virtual { - uint256 releasable = vestedAmount(token, block.timestamp) - released(token); - _released[token] += releasable; - emit TokensReleased(token, releasable); - if (token == address(0)) { - Address.sendValue(payable(beneficiary()), releasable); - } else { - SafeERC20.safeTransfer(IERC20(token), beneficiary(), releasable); - } + function released(address token) public view returns (uint256) { + return _erc20Released[token]; } /** @@ -97,33 +91,45 @@ contract VestingWallet is Context { * Emits a {TokensReleased} event. */ function release() public virtual { - release(address(0)); + uint256 releasable = vestedAmount(block.timestamp) - released(); + _released += releasable; + emit TokensReleased(releasable); + Address.sendValue(payable(beneficiary()), releasable); } /** - * @dev Calculates the amount of tokens that has already vested. Default implementation is a linear vesting curve. + * @dev Release the tokens that have already vested. + * + * Emits a {TokensReleased} event. */ - function vestedAmount(address token, uint256 timestamp) public view virtual returns (uint256) { - if (timestamp < start()) { - return 0; - } else if (timestamp >= start() + duration()) { - return _historicalBalance(token); - } else { - return (_historicalBalance(token) * (timestamp - start())) / duration(); - } + function release(address token) public virtual { + uint256 releasable = vestedAmount(token, block.timestamp) - released(token); + _erc20Released[token] += releasable; + emit ERC20TokensReleased(token, releasable); + SafeERC20.safeTransfer(IERC20(token), beneficiary(), releasable); } /** * @dev Calculates the amount of ether that has already vested. Default implementation is a linear vesting curve. */ function vestedAmount(uint256 timestamp) public view virtual returns (uint256) { - return vestedAmount(address(0), timestamp); + if (timestamp < start()) { + return 0; + } else { + uint256 historicalBalance = address(this).balance + released(); + return Math.min(historicalBalance, historicalBalance * (timestamp - start()) / duration()); + } } /** - * @dev Calculates the historical balance (current balance + already released balance). + * @dev Calculates the amount of tokens that has already vested. Default implementation is a linear vesting curve. */ - function _historicalBalance(address token) internal view virtual returns (uint256) { - return (token == address(0) ? address(this).balance : IERC20(token).balanceOf(address(this))) + released(token); + function vestedAmount(address token, uint256 timestamp) public view virtual returns (uint256) { + if (timestamp < start()) { + return 0; + } else { + uint256 historicalBalance = IERC20(token).balanceOf(address(this)) + released(token); + return Math.min(historicalBalance, historicalBalance * (timestamp - start()) / duration()); + } } } diff --git a/test/finance/VestingWallet.behavior.js b/test/finance/VestingWallet.behavior.js index 2b66dd04bbc..6a3079b09ca 100644 --- a/test/finance/VestingWallet.behavior.js +++ b/test/finance/VestingWallet.behavior.js @@ -20,10 +20,15 @@ function shouldBehaveLikeVesting (beneficiary) { { const receipt = await this.mock.release(...args); - await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { - token: this.token ? this.token.address : constants.ZERO_ADDRESS, - amount: '0', - }); + await expectEvent.inTransaction( + receipt.tx, + this.mock, + this.token ? 'ERC20TokensReleased' : 'TokensReleased', + Object.fromEntries(Object.entries({ + token: this.token && this.token.address, + amount: '0', + }).filter(x => x.every(Boolean))), + ); await this.checkRelease(receipt, beneficiary, '0'); @@ -40,10 +45,15 @@ function shouldBehaveLikeVesting (beneficiary) { const receipt = await this.mock.release(...args); - await expectEvent.inTransaction(receipt.tx, this.mock, 'TokensReleased', { - token: this.token ? this.token.address : constants.ZERO_ADDRESS, - amount: vested.sub(released), - }); + await expectEvent.inTransaction( + receipt.tx, + this.mock, + this.token ? 'ERC20TokensReleased' : 'TokensReleased', + Object.fromEntries(Object.entries({ + token: this.token && this.token.address, + amount: vested.sub(released), + }).filter(x => x.every(Boolean))), + ); await this.checkRelease(receipt, beneficiary, vested.sub(released)); From cc06a12ea5aa90bdec6df6cd32e4b4044de39aeb Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 7 Oct 2021 16:31:26 +0200 Subject: [PATCH 16/22] add cahngelog entry and documentation --- CHANGELOG.md | 1 + contracts/finance/README.adoc | 14 +++++++++++++- contracts/finance/VestingWallet.sol | 4 ++-- test/finance/VestingWallet.behavior.js | 2 +- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb03e735fa..04a86a4749a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * Add internal `_setApprovalForAll` to `ERC721` and `ERC1155`. ([#2834](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2834)) * `Governor`: shift vote start and end by one block to better match Compound's GovernorBravo and prevent voting at the Governor level if the voting snapshot is not ready. ([#2892](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2892)) * `PaymentSplitter`: now supports ERC20 assets in addition to Ether. ([#2858](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2858)) + * `VestingWallet`: new contract that handles the vesting of Eth and ERC20 tokens following a customizable vesting schedule. ([#2748](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2748)) ## 4.3.2 (2021-09-14) diff --git a/contracts/finance/README.adoc b/contracts/finance/README.adoc index 1d119d319a8..9f36402a5a0 100644 --- a/contracts/finance/README.adoc +++ b/contracts/finance/README.adoc @@ -3,8 +3,20 @@ [.readme-notice] NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/api/finance -This directory includes primitives for financial systems. We currently only offer the {PaymentSplitter} contract, but we want to grow this directory so we welcome ideas. +This directory includes primitives for financial systems: + +- {PaymentSplitter} allows to split Eth and ERC20 payments among a group of accounts. The sender does not need to be + aware that the assets will be split in this way, since it is handled transparently by the contract. The split can be + in equal parts or in any other arbitrary proportion. + +- {VestingWallet} handles the vesting of Eth and ERC20 tokens for a given beneficiary. Custody of multiple tokens can + be given to this contract, which will release the token to the beneficiary following a given, customizable, vesting + schedule. == PaymentSplitter {{PaymentSplitter}} + +== VestingWallet + +{{VestingWallet}} diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index 56ede846180..94ed6f7d268 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -117,7 +117,7 @@ contract VestingWallet is Context { return 0; } else { uint256 historicalBalance = address(this).balance + released(); - return Math.min(historicalBalance, historicalBalance * (timestamp - start()) / duration()); + return Math.min(historicalBalance, (historicalBalance * (timestamp - start())) / duration()); } } @@ -129,7 +129,7 @@ contract VestingWallet is Context { return 0; } else { uint256 historicalBalance = IERC20(token).balanceOf(address(this)) + released(token); - return Math.min(historicalBalance, historicalBalance * (timestamp - start()) / duration()); + return Math.min(historicalBalance, (historicalBalance * (timestamp - start())) / duration()); } } } diff --git a/test/finance/VestingWallet.behavior.js b/test/finance/VestingWallet.behavior.js index 6a3079b09ca..25a2ffddb8b 100644 --- a/test/finance/VestingWallet.behavior.js +++ b/test/finance/VestingWallet.behavior.js @@ -1,4 +1,4 @@ -const { constants, expectEvent } = require('@openzeppelin/test-helpers'); +const { expectEvent } = require('@openzeppelin/test-helpers'); const { expect } = require('chai'); function shouldBehaveLikeVesting (beneficiary) { From ec0442b9f42ce734d44a0141229099b0ee9e6a67 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 7 Oct 2021 16:36:31 +0200 Subject: [PATCH 17/22] remove dead code --- contracts/finance/VestingWallet.sol | 5 ----- 1 file changed, 5 deletions(-) diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index 94ed6f7d268..129d46b4c84 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -26,11 +26,6 @@ contract VestingWallet is Context { uint256 private immutable _start; uint256 private immutable _duration; - modifier onlyBeneficiary() { - require(beneficiary() == _msgSender(), "VestingWallet: access restricted to beneficiary"); - _; - } - /** * @dev Set the beneficiary, start timestamp and vesting duration of the vesting wallet. */ From 931a33985658725e12a360899c461c2cd3265251 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 13 Oct 2021 09:40:26 +0200 Subject: [PATCH 18/22] Apply suggestions from code review Co-authored-by: Francisco Giordano --- CHANGELOG.md | 2 +- contracts/finance/README.adoc | 8 +++----- contracts/finance/VestingWallet.sol | 8 ++++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab62b4c4dc..37fb381d03d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ * `Governor`: shift vote start and end by one block to better match Compound's GovernorBravo and prevent voting at the Governor level if the voting snapshot is not ready. ([#2892](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2892)) * `PaymentSplitter`: now supports ERC20 assets in addition to Ether. ([#2858](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2858)) * `ECDSA`: add a variant of `toEthSignedMessageHash` for arbitrary length message hashing. ([#2865](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2865)) - * `VestingWallet`: new contract that handles the vesting of Eth and ERC20 tokens following a customizable vesting schedule. ([#2748](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2748)) + * `VestingWallet`: new contract that handles the vesting of Ether and ERC20 tokens following a customizable vesting schedule. ([#2748](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2748)) ## 4.3.2 (2021-09-14) diff --git a/contracts/finance/README.adoc b/contracts/finance/README.adoc index 9f36402a5a0..b64af312556 100644 --- a/contracts/finance/README.adoc +++ b/contracts/finance/README.adoc @@ -5,18 +5,16 @@ NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/ This directory includes primitives for financial systems: -- {PaymentSplitter} allows to split Eth and ERC20 payments among a group of accounts. The sender does not need to be +- {PaymentSplitter} allows to split Ether and ERC20 payments among a group of accounts. The sender does not need to be aware that the assets will be split in this way, since it is handled transparently by the contract. The split can be in equal parts or in any other arbitrary proportion. -- {VestingWallet} handles the vesting of Eth and ERC20 tokens for a given beneficiary. Custody of multiple tokens can +- {VestingWallet} handles the vesting of Ether and ERC20 tokens for a given beneficiary. Custody of multiple tokens can be given to this contract, which will release the token to the beneficiary following a given, customizable, vesting schedule. -== PaymentSplitter +== Contracts {{PaymentSplitter}} -== VestingWallet - {{VestingWallet}} diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index 129d46b4c84..c134b80380b 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -67,16 +67,16 @@ contract VestingWallet is Context { } /** - * @dev Amont of eth already released + * @dev Amount of eth already released */ - function released() public view returns (uint256) { + function released() public view virtual returns (uint256) { return _released; } /** - * @dev Amont of token already released + * @dev Amount of token already released */ - function released(address token) public view returns (uint256) { + function released(address token) public view virtual returns (uint256) { return _erc20Released[token]; } From 28c00945eeb3b6ecd4d2c0b2aab473ba0d029303 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 13 Oct 2021 09:50:10 +0200 Subject: [PATCH 19/22] address issues raised in the PR --- contracts/finance/VestingWallet.sol | 36 ++++++++++++++------------ test/finance/VestingWallet.behavior.js | 4 +-- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index c134b80380b..5d30bf4398a 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -17,22 +17,22 @@ import "../utils/math/Math.sol"; * be immediately releasable. */ contract VestingWallet is Context { - event TokensReleased(uint256 amount); - event ERC20TokensReleased(address token, uint256 amount); + event EtherReleased(uint256 amount); + event ERC20Released(address token, uint256 amount); uint256 private _released; mapping(address => uint256) private _erc20Released; address private immutable _beneficiary; - uint256 private immutable _start; - uint256 private immutable _duration; + uint64 private immutable _start; + uint64 private immutable _duration; /** * @dev Set the beneficiary, start timestamp and vesting duration of the vesting wallet. */ constructor( address beneficiaryAddress, - uint256 startTimestamp, - uint256 durationSeconds + uint64 startTimestamp, + uint64 durationSeconds ) { require(beneficiaryAddress != address(0), "VestingWallet: beneficiary is zero address"); _beneficiary = beneficiaryAddress; @@ -43,7 +43,7 @@ contract VestingWallet is Context { /** * @dev The contract should be able to receive Eth. */ - receive() external payable {} + receive() external virtual payable {} /** * @dev Getter for the beneficiary address. @@ -88,7 +88,7 @@ contract VestingWallet is Context { function release() public virtual { uint256 releasable = vestedAmount(block.timestamp) - released(); _released += releasable; - emit TokensReleased(releasable); + emit EtherReleased(releasable); Address.sendValue(payable(beneficiary()), releasable); } @@ -100,7 +100,7 @@ contract VestingWallet is Context { function release(address token) public virtual { uint256 releasable = vestedAmount(token, block.timestamp) - released(token); _erc20Released[token] += releasable; - emit ERC20TokensReleased(token, releasable); + emit ERC20Released(token, releasable); SafeERC20.safeTransfer(IERC20(token), beneficiary(), releasable); } @@ -108,23 +108,25 @@ contract VestingWallet is Context { * @dev Calculates the amount of ether that has already vested. Default implementation is a linear vesting curve. */ function vestedAmount(uint256 timestamp) public view virtual returns (uint256) { - if (timestamp < start()) { - return 0; - } else { - uint256 historicalBalance = address(this).balance + released(); - return Math.min(historicalBalance, (historicalBalance * (timestamp - start())) / duration()); - } + return _vestingSchedule(address(this).balance + released(), timestamp); } /** * @dev Calculates the amount of tokens that has already vested. Default implementation is a linear vesting curve. */ function vestedAmount(address token, uint256 timestamp) public view virtual returns (uint256) { + return _vestingSchedule(IERC20(token).balanceOf(address(this)) + released(token), timestamp); + } + + /** + * @dev Virtual implementation of the vesting formula. This returns the amout vested, as a function of time, for + * an asset given its total historical allocation. + */ + function _vestingSchedule(uint256 totalAllocation, uint256 timestamp) internal view virtual returns (uint256) { if (timestamp < start()) { return 0; } else { - uint256 historicalBalance = IERC20(token).balanceOf(address(this)) + released(token); - return Math.min(historicalBalance, (historicalBalance * (timestamp - start())) / duration()); + return Math.min(totalAllocation, (totalAllocation * (timestamp - start())) / duration()); } } } diff --git a/test/finance/VestingWallet.behavior.js b/test/finance/VestingWallet.behavior.js index 25a2ffddb8b..6b00c2f789d 100644 --- a/test/finance/VestingWallet.behavior.js +++ b/test/finance/VestingWallet.behavior.js @@ -23,7 +23,7 @@ function shouldBehaveLikeVesting (beneficiary) { await expectEvent.inTransaction( receipt.tx, this.mock, - this.token ? 'ERC20TokensReleased' : 'TokensReleased', + this.token ? 'ERC20Released' : 'EtherReleased', Object.fromEntries(Object.entries({ token: this.token && this.token.address, amount: '0', @@ -48,7 +48,7 @@ function shouldBehaveLikeVesting (beneficiary) { await expectEvent.inTransaction( receipt.tx, this.mock, - this.token ? 'ERC20TokensReleased' : 'TokensReleased', + this.token ? 'ERC20Released' : 'EtherReleased', Object.fromEntries(Object.entries({ token: this.token && this.token.address, amount: vested.sub(released), From caf050ea6847c262ddfdae30368226f73d7a4c2c Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 13 Oct 2021 18:32:28 +0200 Subject: [PATCH 20/22] fix lint --- contracts/finance/VestingWallet.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index 5d30bf4398a..ec6e7611d60 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -43,7 +43,7 @@ contract VestingWallet is Context { /** * @dev The contract should be able to receive Eth. */ - receive() external virtual payable {} + receive() external payable virtual {} /** * @dev Getter for the beneficiary address. From d7b244566b54025f870fd398acfed7dd1ba53fa1 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 14 Oct 2021 12:22:25 +0200 Subject: [PATCH 21/22] improve testing --- contracts/finance/VestingWallet.sol | 14 ++++++----- test/finance/VestingWallet.behavior.js | 32 ++++++++++++++------------ 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/contracts/finance/VestingWallet.sol b/contracts/finance/VestingWallet.sol index ec6e7611d60..ea4ec912488 100644 --- a/contracts/finance/VestingWallet.sol +++ b/contracts/finance/VestingWallet.sol @@ -86,7 +86,7 @@ contract VestingWallet is Context { * Emits a {TokensReleased} event. */ function release() public virtual { - uint256 releasable = vestedAmount(block.timestamp) - released(); + uint256 releasable = vestedAmount(uint64(block.timestamp)) - released(); _released += releasable; emit EtherReleased(releasable); Address.sendValue(payable(beneficiary()), releasable); @@ -98,7 +98,7 @@ contract VestingWallet is Context { * Emits a {TokensReleased} event. */ function release(address token) public virtual { - uint256 releasable = vestedAmount(token, block.timestamp) - released(token); + uint256 releasable = vestedAmount(token, uint64(block.timestamp)) - released(token); _erc20Released[token] += releasable; emit ERC20Released(token, releasable); SafeERC20.safeTransfer(IERC20(token), beneficiary(), releasable); @@ -107,14 +107,14 @@ contract VestingWallet is Context { /** * @dev Calculates the amount of ether that has already vested. Default implementation is a linear vesting curve. */ - function vestedAmount(uint256 timestamp) public view virtual returns (uint256) { + function vestedAmount(uint64 timestamp) public view virtual returns (uint256) { return _vestingSchedule(address(this).balance + released(), timestamp); } /** * @dev Calculates the amount of tokens that has already vested. Default implementation is a linear vesting curve. */ - function vestedAmount(address token, uint256 timestamp) public view virtual returns (uint256) { + function vestedAmount(address token, uint64 timestamp) public view virtual returns (uint256) { return _vestingSchedule(IERC20(token).balanceOf(address(this)) + released(token), timestamp); } @@ -122,11 +122,13 @@ contract VestingWallet is Context { * @dev Virtual implementation of the vesting formula. This returns the amout vested, as a function of time, for * an asset given its total historical allocation. */ - function _vestingSchedule(uint256 totalAllocation, uint256 timestamp) internal view virtual returns (uint256) { + function _vestingSchedule(uint256 totalAllocation, uint64 timestamp) internal view virtual returns (uint256) { if (timestamp < start()) { return 0; + } else if (timestamp > start() + duration()) { + return totalAllocation; } else { - return Math.min(totalAllocation, (totalAllocation * (timestamp - start())) / duration()); + return (totalAllocation * (timestamp - start())) / duration(); } } } diff --git a/test/finance/VestingWallet.behavior.js b/test/finance/VestingWallet.behavior.js index 6b00c2f789d..e0c3ec7333d 100644 --- a/test/finance/VestingWallet.behavior.js +++ b/test/finance/VestingWallet.behavior.js @@ -1,33 +1,39 @@ const { expectEvent } = require('@openzeppelin/test-helpers'); const { expect } = require('chai'); +function releasedEvent (token, amount) { + return token + ? [ 'ERC20Released', { token: token.address, amount } ] + : [ 'EtherReleased', { amount } ]; +} + function shouldBehaveLikeVesting (beneficiary) { it('check vesting schedule', async function () { - const args = this.token ? [ this.token.address ] : []; + const [ method, ...args ] = this.token + ? [ 'vestedAmount(address,uint64)', this.token.address] + : [ 'vestedAmount(uint64)' ]; for (const timestamp of this.schedule) { - expect(await this.mock.vestedAmount(...args, timestamp)) + expect(await this.mock.methods[method](...args, timestamp)) .to.be.bignumber.equal(this.vestingFn(timestamp)); } }); it('execute vesting schedule', async function () { - const args = this.token ? [ this.token.address ] : []; + const [ method, ...args ] = this.token + ? [ 'release(address)', this.token.address] + : [ 'release()' ]; let released = web3.utils.toBN(0); const before = await this.getBalance(beneficiary); { - const receipt = await this.mock.release(...args); + const receipt = await this.mock.methods[method](...args); await expectEvent.inTransaction( receipt.tx, this.mock, - this.token ? 'ERC20Released' : 'EtherReleased', - Object.fromEntries(Object.entries({ - token: this.token && this.token.address, - amount: '0', - }).filter(x => x.every(Boolean))), + ...releasedEvent(this.token, '0'), ); await this.checkRelease(receipt, beneficiary, '0'); @@ -43,16 +49,12 @@ function shouldBehaveLikeVesting (beneficiary) { params: [ timestamp.toNumber() ], }, resolve)); - const receipt = await this.mock.release(...args); + const receipt = await this.mock.methods[method](...args); await expectEvent.inTransaction( receipt.tx, this.mock, - this.token ? 'ERC20Released' : 'EtherReleased', - Object.fromEntries(Object.entries({ - token: this.token && this.token.address, - amount: vested.sub(released), - }).filter(x => x.every(Boolean))), + ...releasedEvent(this.token, vested.sub(released)), ); await this.checkRelease(receipt, beneficiary, vested.sub(released)); From 0a170d4587797fbedf7bcc9b2f6d76adc6fe3e2b Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 14 Oct 2021 12:22:55 +0200 Subject: [PATCH 22/22] linting in tests --- test/finance/VestingWallet.behavior.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/finance/VestingWallet.behavior.js b/test/finance/VestingWallet.behavior.js index e0c3ec7333d..0f07e5f459d 100644 --- a/test/finance/VestingWallet.behavior.js +++ b/test/finance/VestingWallet.behavior.js @@ -10,7 +10,7 @@ function releasedEvent (token, amount) { function shouldBehaveLikeVesting (beneficiary) { it('check vesting schedule', async function () { const [ method, ...args ] = this.token - ? [ 'vestedAmount(address,uint64)', this.token.address] + ? [ 'vestedAmount(address,uint64)', this.token.address ] : [ 'vestedAmount(uint64)' ]; for (const timestamp of this.schedule) { @@ -21,7 +21,7 @@ function shouldBehaveLikeVesting (beneficiary) { it('execute vesting schedule', async function () { const [ method, ...args ] = this.token - ? [ 'release(address)', this.token.address] + ? [ 'release(address)', this.token.address ] : [ 'release()' ]; let released = web3.utils.toBN(0);