diff --git a/contracts/finance/VestingWalletRevocable.sol b/contracts/finance/VestingWalletRevocable.sol new file mode 100644 index 00000000000..4fee15857f0 --- /dev/null +++ b/contracts/finance/VestingWalletRevocable.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.1.0) (finance/VestingWalletRevocable.sol) + +pragma solidity ^0.8.20; + +import {VestingWallet} from "./VestingWallet.sol"; +import {SafeERC20} from "../token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "../token/ERC20/IERC20.sol"; +import {Address} from "../utils/Address.sol"; + +/** + * @dev Extension of {VestingWallet} that lets the owner revoke the vesting schedule. Unvested ETH and any + * listed ERC20 tokens are returned to the owner, and the vested amount is frozen at the revocation timestamp. + * + * _Available since v5.1._ + */ +abstract contract VestingWalletRevocable is VestingWallet { + using SafeERC20 for IERC20; + + bool private _revoked; + uint64 private _revokedAt; + uint256 private _ethAllocationSnapshot; + mapping(address token => uint256) private _erc20AllocationSnapshot; + + /// @dev The vesting schedule has already been revoked. + error AlreadyRevoked(); + + /// @dev Emitted when the vesting schedule is revoked. + event VestingRevoked(address indexed owner); + + /// @dev Whether the vesting schedule has been revoked. + function isRevoked() public view returns (bool) { + return _revoked; + } + + /** + * @dev Revokes the vesting schedule. The unvested portion of ETH and each listed ERC20 token is sent to + * the owner. Tokens omitted from `tokens` stay in the contract and continue to vest against their on-chain + * balance at the revocation timestamp. + * + * Emits a {VestingRevoked} event. + */ + function revoke(address[] calldata tokens) external onlyOwner { + if (_revoked) revert AlreadyRevoked(); + + uint64 t = uint64(block.timestamp); + _revokedAt = t; + _ethAllocationSnapshot = address(this).balance + released(); + for (uint256 i = 0; i < tokens.length; i++) { + _erc20AllocationSnapshot[tokens[i]] = IERC20(tokens[i]).balanceOf(address(this)) + released(tokens[i]); + } + _revoked = true; + + uint256 unvestedEth = address(this).balance - _vestingSchedule(_ethAllocationSnapshot, t); + if (unvestedEth > 0) Address.sendValue(payable(owner()), unvestedEth); + + for (uint256 i = 0; i < tokens.length; i++) { + uint256 unvested = IERC20(tokens[i]).balanceOf(address(this)) - + _vestingSchedule(_erc20AllocationSnapshot[tokens[i]], t); + if (unvested > 0) IERC20(tokens[i]).safeTransfer(owner(), unvested); + } + + emit VestingRevoked(owner()); + } + + /// @dev After revocation, returns the vested amount frozen at the revocation timestamp. + function vestedAmount(uint64 timestamp) public view virtual override returns (uint256) { + return _revoked ? _vestingSchedule(_ethAllocationSnapshot, _revokedAt) : super.vestedAmount(timestamp); + } + + /** + * @dev After revocation, returns the token's vested amount frozen at the revocation timestamp. Tokens + * omitted from {revoke} keep their on-chain balance and vest against it using the revocation timestamp. + */ + function vestedAmount(address token, uint64 timestamp) public view virtual override returns (uint256) { + if (!_revoked) return super.vestedAmount(token, timestamp); + uint256 snapshot = _erc20AllocationSnapshot[token]; + return snapshot == 0 ? super.vestedAmount(token, _revokedAt) : _vestingSchedule(snapshot, _revokedAt); + } +} diff --git a/test/finance/VestingWalletRevocable.test.js b/test/finance/VestingWalletRevocable.test.js new file mode 100644 index 00000000000..eadc479cf3d --- /dev/null +++ b/test/finance/VestingWalletRevocable.test.js @@ -0,0 +1,109 @@ +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 { envSetup, 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 [sender, beneficiary] = await ethers.getSigners(); + const mock = await ethers.deployContract('$VestingWalletRevocable', [beneficiary, start, duration]); + + const token = await ethers.deployContract('$ERC20', ['Name', 'Symbol']); + await token.$_mint(mock, amount); + await sender.sendTransaction({ to: mock, value: amount }); + + const env = await envSetup(mock, beneficiary, token); + + const schedule = Array.from({ length: 64 }, (_, i) => (BigInt(i) * duration) / 60n + start); + const vestingFn = timestamp => min(amount, (amount * (timestamp - start)) / duration); + + return { mock, duration, start, beneficiary, token, env, schedule, vestingFn }; +} + +describe('VestingWalletRevocable', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + 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.isRevoked()).to.be.false; + }); + + 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(); + }); + }); + + describe('revoke', function () { + it('reverts when caller is not owner', async function () { + const [, , other] = await ethers.getSigners(); + await expect(this.mock.connect(other).revoke([])) + .to.be.revertedWithCustomError(this.mock, 'OwnableUnauthorizedAccount') + .withArgs(other.address); + }); + + it('reverts on second revoke', async function () { + await this.mock.connect(this.beneficiary).revoke([this.token.target]); + await expect(this.mock.connect(this.beneficiary).revoke([this.token.target])).to.be.revertedWithCustomError( + this.mock, + 'AlreadyRevoked', + ); + }); + + it('returns unvested funds to owner', async function () { + await time.increaseTo.timestamp(this.start + this.duration / 4n, false); + const tx = await this.mock.connect(this.beneficiary).revoke([this.token.target]); + const unvested = ethers.parseEther('75'); + + await expect(tx).to.emit(this.mock, 'VestingRevoked').withArgs(this.beneficiary.address); + await expect(tx).to.changeEtherBalances([this.mock, this.beneficiary], [-unvested, unvested]); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.beneficiary], [-unvested, unvested]); + expect(await this.mock.isRevoked()).to.be.true; + }); + + it('freezes vested amount at revocation timestamp', async function () { + const revokeAt = this.start + this.duration / 4n; + await time.increaseTo.timestamp(revokeAt, false); + const vestedAtRevoke = await this.mock.vestedAmount(revokeAt); + await this.mock.connect(this.beneficiary).revoke([this.token.target]); + + await time.increaseTo.timestamp(this.start + this.duration); + expect(await this.mock.vestedAmount(this.start + this.duration)).to.equal(vestedAtRevoke); + }); + + it('keeps un-listed token vesting against on-chain balance', async function () { + const other = await ethers.deployContract('$ERC20', ['Other', 'OTH']); + await other.$_mint(this.mock, ethers.parseEther('100')); + + await time.increaseTo.timestamp(this.start + this.duration / 4n, false); + await this.mock.connect(this.beneficiary).revoke([this.token.target]); + + await time.increaseTo.timestamp(this.start + this.duration); + expect(await this.mock.releasable(ethers.Typed.address(other))).to.equal(ethers.parseEther('25')); + }); + }); +});