Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Francisco Giordano <frangio.1@gmail.com>
- Loading branch information
Showing
5 changed files
with
286 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
// SPDX-License-Identifier: MIT | ||
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 | ||
* @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 VestingWallet is Context { | ||
event EtherReleased(uint256 amount); | ||
event ERC20Released(address token, uint256 amount); | ||
|
||
uint256 private _released; | ||
mapping(address => uint256) private _erc20Released; | ||
address private immutable _beneficiary; | ||
uint64 private immutable _start; | ||
uint64 private immutable _duration; | ||
|
||
/** | ||
* @dev Set the beneficiary, start timestamp and vesting duration of the vesting wallet. | ||
*/ | ||
constructor( | ||
address beneficiaryAddress, | ||
uint64 startTimestamp, | ||
uint64 durationSeconds | ||
) { | ||
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 virtual {} | ||
|
||
/** | ||
* @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 Amount of eth already released | ||
*/ | ||
function released() public view virtual returns (uint256) { | ||
return _released; | ||
} | ||
|
||
/** | ||
* @dev Amount of token already released | ||
*/ | ||
function released(address token) public view virtual returns (uint256) { | ||
return _erc20Released[token]; | ||
} | ||
|
||
/** | ||
* @dev Release the native token (ether) that have already vested. | ||
* | ||
* Emits a {TokensReleased} event. | ||
*/ | ||
function release() public virtual { | ||
uint256 releasable = vestedAmount(uint64(block.timestamp)) - released(); | ||
_released += releasable; | ||
emit EtherReleased(releasable); | ||
Address.sendValue(payable(beneficiary()), releasable); | ||
} | ||
|
||
/** | ||
* @dev Release the tokens that have already vested. | ||
* | ||
* Emits a {TokensReleased} event. | ||
*/ | ||
function release(address token) public virtual { | ||
uint256 releasable = vestedAmount(token, uint64(block.timestamp)) - released(token); | ||
_erc20Released[token] += releasable; | ||
emit ERC20Released(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(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, uint64 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, uint64 timestamp) internal view virtual returns (uint256) { | ||
if (timestamp < start()) { | ||
return 0; | ||
} else if (timestamp > start() + duration()) { | ||
return totalAllocation; | ||
} else { | ||
return (totalAllocation * (timestamp - start())) / duration(); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
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 [ method, ...args ] = this.token | ||
? [ 'vestedAmount(address,uint64)', this.token.address ] | ||
: [ 'vestedAmount(uint64)' ]; | ||
|
||
for (const timestamp of this.schedule) { | ||
expect(await this.mock.methods[method](...args, timestamp)) | ||
.to.be.bignumber.equal(this.vestingFn(timestamp)); | ||
} | ||
}); | ||
|
||
it('execute vesting schedule', async function () { | ||
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.methods[method](...args); | ||
|
||
await expectEvent.inTransaction( | ||
receipt.tx, | ||
this.mock, | ||
...releasedEvent(this.token, '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.methods[method](...args); | ||
|
||
await expectEvent.inTransaction( | ||
receipt.tx, | ||
this.mock, | ||
...releasedEvent(this.token, 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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); | ||
}); |