Add VestingWalletWithCliff (#4870)
Co-authored-by: Ernesto García <ernestognw@gmail.com>pull/4897/head
parent
f8b1ddf591
commit
ae1bafcb48
@ -0,0 +1,5 @@ |
||||
--- |
||||
'openzeppelin-solidity': minor |
||||
--- |
||||
|
||||
`VestingWalletCliff`: Add an extension of the `VestingWallet` contract with an added cliff. |
@ -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); |
||||
} |
||||
} |
@ -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…
Reference in new issue