Browse Source

Add a VestingWallet (#2748)

Co-authored-by: Francisco Giordano <frangio.1@gmail.com>
Hadrien Croubois 4 years ago
parent
commit
88e4b69bfb

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@
  * `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))
  * `MerkleProof`: add a `processProof` function that returns the rebuilt root hash given a leaf and a proof. ([#2841](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2841))
+ * `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)
 

+ 12 - 2
contracts/finance/README.adoc

@@ -3,8 +3,18 @@
 [.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
+- {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 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.
+
+== Contracts
 
 {{PaymentSplitter}}
+
+{{VestingWallet}}

+ 134 - 0
contracts/finance/VestingWallet.sol

@@ -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();
+        }
+    }
+}

+ 72 - 0
test/finance/VestingWallet.behavior.js

@@ -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,
+};

+ 67 - 0
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);
+    });
+  });
+});