From ae1bafcb48fe220257d76bfd93a237db3ebaf3df Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 13 Feb 2024 10:01:45 +0100 Subject: [PATCH] Add VestingWalletWithCliff (#4870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernesto GarcĂ­a --- .changeset/wise-bobcats-speak.md | 5 ++ contracts/finance/VestingWalletCliff.sol | 51 +++++++++++ hardhat.config.js | 2 +- test/finance/VestingWalletCliff.test.js | 107 +++++++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 .changeset/wise-bobcats-speak.md create mode 100644 contracts/finance/VestingWalletCliff.sol create mode 100644 test/finance/VestingWalletCliff.test.js diff --git a/.changeset/wise-bobcats-speak.md b/.changeset/wise-bobcats-speak.md new file mode 100644 index 000000000..6ecd96957 --- /dev/null +++ b/.changeset/wise-bobcats-speak.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`VestingWalletCliff`: Add an extension of the `VestingWallet` contract with an added cliff. diff --git a/contracts/finance/VestingWalletCliff.sol b/contracts/finance/VestingWalletCliff.sol new file mode 100644 index 000000000..d98be640b --- /dev/null +++ b/contracts/finance/VestingWalletCliff.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {SafeCast} from "../utils/math/SafeCast.sol"; +import {VestingWallet} from "./VestingWallet.sol"; + +/** + * @dev Extension of {VestingWallet} that adds a cliff to the vesting schedule. + */ +abstract contract VestingWalletCliff is VestingWallet { + using SafeCast for *; + + uint64 private immutable _cliff; + + /// @dev The specified cliff duration is larger than the vesting duration. + error InvalidCliffDuration(uint64 cliffSeconds, uint64 durationSeconds); + + /** + * @dev Sets the sender as the initial owner, the beneficiary as the pending owner, the start timestamp, the + * vesting duration and the duration of the cliff of the vesting wallet. + */ + constructor(uint64 cliffSeconds) { + if (cliffSeconds > duration()) { + revert InvalidCliffDuration(cliffSeconds, duration().toUint64()); + } + _cliff = start().toUint64() + cliffSeconds; + } + + /** + * @dev Getter for the cliff timestamp. + */ + function cliff() public view virtual returns (uint256) { + return _cliff; + } + + /** + * @dev Virtual implementation of the vesting formula. This returns the amount vested, as a function of time, for + * an asset given its total historical allocation. Returns 0 if the {cliff} timestamp is not met. + * + * IMPORTANT: The cliff not only makes the schedule return 0, but it also ignores every possible side + * effect from calling the inherited implementation (i.e. `super._vestingSchedule`). Carefully consider + * this caveat if the overridden implementation of this function has any (e.g. writing to memory or reverting). + */ + function _vestingSchedule( + uint256 totalAllocation, + uint64 timestamp + ) internal view virtual override returns (uint256) { + return timestamp < cliff() ? 0 : super._vestingSchedule(totalAllocation, timestamp); + } +} diff --git a/hardhat.config.js b/hardhat.config.js index 230cca5e2..b91a88658 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -102,7 +102,7 @@ module.exports = { exposed: { imports: true, initializers: true, - exclude: ['vendor/**/*'], + exclude: ['vendor/**/*', '**/*WithInit.sol'], }, gasReporter: { enabled: argv.gas, diff --git a/test/finance/VestingWalletCliff.test.js b/test/finance/VestingWalletCliff.test.js new file mode 100644 index 000000000..810769f5c --- /dev/null +++ b/test/finance/VestingWalletCliff.test.js @@ -0,0 +1,107 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { min } = require('../helpers/math'); +const time = require('../helpers/time'); + +const { shouldBehaveLikeVesting } = require('./VestingWallet.behavior'); + +async function fixture() { + const amount = ethers.parseEther('100'); + const duration = time.duration.years(4); + const start = (await time.clock.timestamp()) + time.duration.hours(1); + const cliffDuration = time.duration.years(1); + const cliff = start + cliffDuration; + + const [sender, beneficiary] = await ethers.getSigners(); + const mock = await ethers.deployContract('$VestingWalletCliff', [beneficiary, start, duration, cliffDuration]); + + const token = await ethers.deployContract('$ERC20', ['Name', 'Symbol']); + await token.$_mint(mock, amount); + await sender.sendTransaction({ to: mock, value: amount }); + + const pausableToken = await ethers.deployContract('$ERC20Pausable', ['Name', 'Symbol']); + const beneficiaryMock = await ethers.deployContract('EtherReceiverMock'); + + const env = { + eth: { + checkRelease: async (tx, amount) => { + await expect(tx).to.emit(mock, 'EtherReleased').withArgs(amount); + await expect(tx).to.changeEtherBalances([mock, beneficiary], [-amount, amount]); + }, + setupFailure: async () => { + await beneficiaryMock.setAcceptEther(false); + await mock.connect(beneficiary).transferOwnership(beneficiaryMock); + return { args: [], error: [mock, 'FailedInnerCall'] }; + }, + releasedEvent: 'EtherReleased', + argsVerify: [], + args: [], + }, + token: { + checkRelease: async (tx, amount) => { + await expect(tx).to.emit(token, 'Transfer').withArgs(mock, beneficiary, amount); + await expect(tx).to.changeTokenBalances(token, [mock, beneficiary], [-amount, amount]); + }, + setupFailure: async () => { + await pausableToken.$_pause(); + return { + args: [ethers.Typed.address(pausableToken)], + error: [pausableToken, 'EnforcedPause'], + }; + }, + releasedEvent: 'ERC20Released', + argsVerify: [token], + args: [ethers.Typed.address(token)], + }, + }; + + const schedule = Array(64) + .fill() + .map((_, i) => (BigInt(i) * duration) / 60n + start); + + const vestingFn = timestamp => min(amount, timestamp < cliff ? 0n : (amount * (timestamp - start)) / duration); + + return { mock, duration, start, beneficiary, cliff, schedule, vestingFn, env }; +} + +describe('VestingWalletCliff', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('rejects a larger cliff than vesting duration', async function () { + await expect( + ethers.deployContract('$VestingWalletCliff', [this.beneficiary, this.start, this.duration, this.duration + 1n]), + ) + .revertedWithCustomError(this.mock, 'InvalidCliffDuration') + .withArgs(this.duration + 1n, this.duration); + }); + + it('check vesting contract', async function () { + expect(await this.mock.owner()).to.equal(this.beneficiary); + expect(await this.mock.start()).to.equal(this.start); + expect(await this.mock.duration()).to.equal(this.duration); + expect(await this.mock.end()).to.equal(this.start + this.duration); + expect(await this.mock.cliff()).to.equal(this.cliff); + }); + + describe('vesting schedule', function () { + describe('Eth vesting', function () { + beforeEach(async function () { + Object.assign(this, this.env.eth); + }); + + shouldBehaveLikeVesting(); + }); + + describe('ERC20 vesting', function () { + beforeEach(async function () { + Object.assign(this, this.env.token); + }); + + shouldBehaveLikeVesting(); + }); + }); +});