forked from OpenZeppelin/openzeppelin-contracts
-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add VestingWalletWithCliff (OpenZeppelin#4870)
Co-authored-by: Ernesto García <[email protected]>
- Loading branch information
1 parent
f8b1ddf
commit ae1bafc
Showing
4 changed files
with
164 additions
and
1 deletion.
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
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. |
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,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); | ||
} | ||
} |
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,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(); | ||
}); | ||
}); | ||
}); |