Skip to content

Commit ae1bafc

Browse files
Amxxernestognw
andauthored
Add VestingWalletWithCliff (#4870)
Co-authored-by: Ernesto García <[email protected]>
1 parent f8b1ddf commit ae1bafc

File tree

4 files changed

+164
-1
lines changed

4 files changed

+164
-1
lines changed

.changeset/wise-bobcats-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'openzeppelin-solidity': minor
3+
---
4+
5+
`VestingWalletCliff`: Add an extension of the `VestingWallet` contract with an added cliff.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {SafeCast} from "../utils/math/SafeCast.sol";
6+
import {VestingWallet} from "./VestingWallet.sol";
7+
8+
/**
9+
* @dev Extension of {VestingWallet} that adds a cliff to the vesting schedule.
10+
*/
11+
abstract contract VestingWalletCliff is VestingWallet {
12+
using SafeCast for *;
13+
14+
uint64 private immutable _cliff;
15+
16+
/// @dev The specified cliff duration is larger than the vesting duration.
17+
error InvalidCliffDuration(uint64 cliffSeconds, uint64 durationSeconds);
18+
19+
/**
20+
* @dev Sets the sender as the initial owner, the beneficiary as the pending owner, the start timestamp, the
21+
* vesting duration and the duration of the cliff of the vesting wallet.
22+
*/
23+
constructor(uint64 cliffSeconds) {
24+
if (cliffSeconds > duration()) {
25+
revert InvalidCliffDuration(cliffSeconds, duration().toUint64());
26+
}
27+
_cliff = start().toUint64() + cliffSeconds;
28+
}
29+
30+
/**
31+
* @dev Getter for the cliff timestamp.
32+
*/
33+
function cliff() public view virtual returns (uint256) {
34+
return _cliff;
35+
}
36+
37+
/**
38+
* @dev Virtual implementation of the vesting formula. This returns the amount vested, as a function of time, for
39+
* an asset given its total historical allocation. Returns 0 if the {cliff} timestamp is not met.
40+
*
41+
* IMPORTANT: The cliff not only makes the schedule return 0, but it also ignores every possible side
42+
* effect from calling the inherited implementation (i.e. `super._vestingSchedule`). Carefully consider
43+
* this caveat if the overridden implementation of this function has any (e.g. writing to memory or reverting).
44+
*/
45+
function _vestingSchedule(
46+
uint256 totalAllocation,
47+
uint64 timestamp
48+
) internal view virtual override returns (uint256) {
49+
return timestamp < cliff() ? 0 : super._vestingSchedule(totalAllocation, timestamp);
50+
}
51+
}

hardhat.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ module.exports = {
102102
exposed: {
103103
imports: true,
104104
initializers: true,
105-
exclude: ['vendor/**/*'],
105+
exclude: ['vendor/**/*', '**/*WithInit.sol'],
106106
},
107107
gasReporter: {
108108
enabled: argv.gas,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const { ethers } = require('hardhat');
2+
const { expect } = require('chai');
3+
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
4+
5+
const { min } = require('../helpers/math');
6+
const time = require('../helpers/time');
7+
8+
const { shouldBehaveLikeVesting } = require('./VestingWallet.behavior');
9+
10+
async function fixture() {
11+
const amount = ethers.parseEther('100');
12+
const duration = time.duration.years(4);
13+
const start = (await time.clock.timestamp()) + time.duration.hours(1);
14+
const cliffDuration = time.duration.years(1);
15+
const cliff = start + cliffDuration;
16+
17+
const [sender, beneficiary] = await ethers.getSigners();
18+
const mock = await ethers.deployContract('$VestingWalletCliff', [beneficiary, start, duration, cliffDuration]);
19+
20+
const token = await ethers.deployContract('$ERC20', ['Name', 'Symbol']);
21+
await token.$_mint(mock, amount);
22+
await sender.sendTransaction({ to: mock, value: amount });
23+
24+
const pausableToken = await ethers.deployContract('$ERC20Pausable', ['Name', 'Symbol']);
25+
const beneficiaryMock = await ethers.deployContract('EtherReceiverMock');
26+
27+
const env = {
28+
eth: {
29+
checkRelease: async (tx, amount) => {
30+
await expect(tx).to.emit(mock, 'EtherReleased').withArgs(amount);
31+
await expect(tx).to.changeEtherBalances([mock, beneficiary], [-amount, amount]);
32+
},
33+
setupFailure: async () => {
34+
await beneficiaryMock.setAcceptEther(false);
35+
await mock.connect(beneficiary).transferOwnership(beneficiaryMock);
36+
return { args: [], error: [mock, 'FailedInnerCall'] };
37+
},
38+
releasedEvent: 'EtherReleased',
39+
argsVerify: [],
40+
args: [],
41+
},
42+
token: {
43+
checkRelease: async (tx, amount) => {
44+
await expect(tx).to.emit(token, 'Transfer').withArgs(mock, beneficiary, amount);
45+
await expect(tx).to.changeTokenBalances(token, [mock, beneficiary], [-amount, amount]);
46+
},
47+
setupFailure: async () => {
48+
await pausableToken.$_pause();
49+
return {
50+
args: [ethers.Typed.address(pausableToken)],
51+
error: [pausableToken, 'EnforcedPause'],
52+
};
53+
},
54+
releasedEvent: 'ERC20Released',
55+
argsVerify: [token],
56+
args: [ethers.Typed.address(token)],
57+
},
58+
};
59+
60+
const schedule = Array(64)
61+
.fill()
62+
.map((_, i) => (BigInt(i) * duration) / 60n + start);
63+
64+
const vestingFn = timestamp => min(amount, timestamp < cliff ? 0n : (amount * (timestamp - start)) / duration);
65+
66+
return { mock, duration, start, beneficiary, cliff, schedule, vestingFn, env };
67+
}
68+
69+
describe('VestingWalletCliff', function () {
70+
beforeEach(async function () {
71+
Object.assign(this, await loadFixture(fixture));
72+
});
73+
74+
it('rejects a larger cliff than vesting duration', async function () {
75+
await expect(
76+
ethers.deployContract('$VestingWalletCliff', [this.beneficiary, this.start, this.duration, this.duration + 1n]),
77+
)
78+
.revertedWithCustomError(this.mock, 'InvalidCliffDuration')
79+
.withArgs(this.duration + 1n, this.duration);
80+
});
81+
82+
it('check vesting contract', async function () {
83+
expect(await this.mock.owner()).to.equal(this.beneficiary);
84+
expect(await this.mock.start()).to.equal(this.start);
85+
expect(await this.mock.duration()).to.equal(this.duration);
86+
expect(await this.mock.end()).to.equal(this.start + this.duration);
87+
expect(await this.mock.cliff()).to.equal(this.cliff);
88+
});
89+
90+
describe('vesting schedule', function () {
91+
describe('Eth vesting', function () {
92+
beforeEach(async function () {
93+
Object.assign(this, this.env.eth);
94+
});
95+
96+
shouldBehaveLikeVesting();
97+
});
98+
99+
describe('ERC20 vesting', function () {
100+
beforeEach(async function () {
101+
Object.assign(this, this.env.token);
102+
});
103+
104+
shouldBehaveLikeVesting();
105+
});
106+
});
107+
});

0 commit comments

Comments
 (0)