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 VestingWalletWithCliff #4870

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wise-bobcats-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`VestingWalletCliff`: Add an extension of the `VestingWallet` contract with an added cliff.
51 changes: 51 additions & 0 deletions contracts/finance/VestingWalletCliff.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 1 addition & 1 deletion hardhat.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ module.exports = {
exposed: {
imports: true,
initializers: true,
exclude: ['vendor/**/*'],
exclude: ['vendor/**/*', '**/*WithInit.sol'],
},
gasReporter: {
enabled: argv.gas,
Expand Down
107 changes: 107 additions & 0 deletions test/finance/VestingWalletCliff.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading