Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a VestingWallet #2748

Merged
merged 26 commits into from Oct 18, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 25 additions & 0 deletions 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
Amxx marked this conversation as resolved.
Show resolved Hide resolved
* @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);
}
}
103 changes: 103 additions & 0 deletions contracts/finance/vesting/ERC20VestingWallet.sol
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../../token/ERC20/utils/SafeERC20.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.
*
* 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 {
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 Amont of token already released
*/
function released(address token) public view 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 virtual returns (uint256) {
return IERC20(token).balanceOf(address(this)) + released(token);
}
}
87 changes: 87 additions & 0 deletions 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) {
Amxx marked this conversation as resolved.
Show resolved Hide resolved
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);
}
});
});
});
118 changes: 118 additions & 0 deletions test/finance/vesting/ERC20VestingWalletVoting.test.js
@@ -0,0 +1,118 @@
const { constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers');
const { expect } = require('chai');

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.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)),
}));
});

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.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 }),
'ERC20VestingWallet: 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);
});
});
});