Skip to content
Open
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
3 changes: 3 additions & 0 deletions contracts/finance/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ This directory includes primitives for financial systems:
- {VestingWallet} handles the vesting of Ether and ERC-20 tokens for a given beneficiary. Custody of multiple tokens can
be given to this contract, which will release the token to the beneficiary following a given, customizable, vesting
schedule.
- {VestingWalletRevocable} extends {VestingWallet} with per-asset revocation, freezing vesting for a revoked asset and
returning its unvested portion to a designated revoker.

== Contracts

{{VestingWallet}}
{{VestingWalletRevocable}}
149 changes: 149 additions & 0 deletions contracts/finance/VestingWalletRevocable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {IERC20} from "../token/ERC20/IERC20.sol";
import {SafeERC20} from "../token/ERC20/utils/SafeERC20.sol";
import {Address} from "../utils/Address.sol";
import {VestingWallet} from "./VestingWallet.sol";

/**
* @dev Extension of {VestingWallet} that adds revocation per asset.
*
* The beneficiary remains the contract owner inherited from {VestingWallet}. A separate revoker account can cancel the
* vesting of the native asset or of specific ERC-20 tokens. When an asset is revoked, the vesting schedule for that
* asset is frozen at the revocation timestamp, the unvested portion is returned to the revoker, and the vested portion
* remains claimable by the beneficiary.
*
* NOTE: Assets transferred to the contract after they have been revoked for a given asset type are not included in the
* frozen historical allocation used for vesting calculations.
*/
contract VestingWalletRevocable is VestingWallet {
event EtherRevoked(uint256 amount);
event ERC20Revoked(address indexed token, uint256 amount);

error VestingWalletInvalidRevoker(address revoker);
error VestingWalletUnauthorizedRevoker(address account);
error VestingWalletEtherAlreadyRevoked();
error VestingWalletERC20AlreadyRevoked(address token);

address private immutable _revoker;
uint64 private _etherRevocationTimestamp;
uint256 private _etherAllocationAtRevocation;
mapping(address token => uint64) private _erc20RevocationTimestamp;
mapping(address token => uint256) private _erc20AllocationAtRevocation;

constructor(
address beneficiary,
address revoker_,
uint64 startTimestamp,
uint64 durationSeconds
) payable VestingWallet(beneficiary, startTimestamp, durationSeconds) {
if (revoker_ == address(0)) {
revert VestingWalletInvalidRevoker(address(0));
}
_revoker = revoker_;
}

modifier onlyRevoker() {
if (_msgSender() != revoker()) {
revert VestingWalletUnauthorizedRevoker(_msgSender());
}
_;
}

/**
* @dev Getter for the address allowed to revoke vesting.
*/
function revoker() public view virtual returns (address) {
return _revoker;
}

/**
* @dev Returns true if the native asset vesting has been revoked.
*/
function revoked() public view virtual returns (bool) {
return _etherRevocationTimestamp != 0;
}

/**
* @dev Returns true if the vesting of `token` has been revoked.
*/
function revoked(address token) public view virtual returns (bool) {
return _erc20RevocationTimestamp[token] != 0;
}

/**
* @dev Revokes the native asset vesting, returning the unvested amount to the revoker.
*
* Emits an {EtherRevoked} event.
*/
function revoke() public virtual onlyRevoker {
if (revoked()) {
revert VestingWalletEtherAlreadyRevoked();
}

uint64 timestamp = uint64(block.timestamp);
uint256 totalAllocation = address(this).balance + released();
uint256 vested = _vestingSchedule(totalAllocation, timestamp);
uint256 refund = totalAllocation - vested;

_etherRevocationTimestamp = timestamp;
_etherAllocationAtRevocation = totalAllocation;

emit EtherRevoked(refund);
Address.sendValue(payable(revoker()), refund);
}

/**
* @dev Revokes the vesting of `token`, returning the unvested amount to the revoker.
*
* Emits an {ERC20Revoked} event.
*/
function revoke(address token) public virtual onlyRevoker {
if (revoked(token)) {
revert VestingWalletERC20AlreadyRevoked(token);
}

uint64 timestamp = uint64(block.timestamp);
uint256 totalAllocation = IERC20(token).balanceOf(address(this)) + released(token);
uint256 vested = _vestingSchedule(totalAllocation, timestamp);
uint256 refund = totalAllocation - vested;

_erc20RevocationTimestamp[token] = timestamp;
_erc20AllocationAtRevocation[token] = totalAllocation;

emit ERC20Revoked(token, refund);
SafeERC20.safeTransfer(IERC20(token), revoker(), refund);
}

/**
* @dev Calculates the amount of ether that has already vested. Once revoked, the native asset vesting is frozen at
* the revocation timestamp and keeps using the historical allocation that existed at revocation time.
*/
function vestedAmount(uint64 timestamp) public view virtual override returns (uint256) {
uint64 revocationTimestamp = _etherRevocationTimestamp;
if (revocationTimestamp == 0) {
return super.vestedAmount(timestamp);
}

return _vestingSchedule(_etherAllocationAtRevocation, _min(timestamp, revocationTimestamp));
}

/**
* @dev Calculates the amount of tokens that have already vested. Once revoked, the token vesting is frozen at the
* revocation timestamp and keeps using the historical allocation that existed at revocation time.
*/
function vestedAmount(address token, uint64 timestamp) public view virtual override returns (uint256) {
uint64 revocationTimestamp = _erc20RevocationTimestamp[token];
if (revocationTimestamp == 0) {
return super.vestedAmount(token, timestamp);
}

return _vestingSchedule(_erc20AllocationAtRevocation[token], _min(timestamp, revocationTimestamp));
}

function _min(uint64 a, uint64 b) private pure returns (uint64) {
return a < b ? a : b;
}
}
158 changes: 158 additions & 0 deletions test/finance/VestingWalletRevocable.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');

const time = require('../helpers/time');

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, revoker, other] = await ethers.getSigners();
const mock = await ethers.deployContract('VestingWalletRevocable', [beneficiary, revoker, start, duration]);

const token = await ethers.deployContract('$ERC20', ['Name', 'Symbol']);
await token.$_mint(mock, amount);
await sender.sendTransaction({ to: mock, value: amount });

return { mock, token, amount, duration, start, beneficiary, revoker, other };
}

describe('VestingWalletRevocable', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});

it('sets the beneficiary as the owner and stores the revoker', async function () {
expect(await this.mock.owner()).to.equal(this.beneficiary);
expect(await this.mock.revoker()).to.equal(this.revoker);
});

it('rejects the zero address revoker', async function () {
await expect(
ethers.deployContract('VestingWalletRevocable', [
this.beneficiary,
ethers.ZeroAddress,
this.start,
this.duration,
]),
)
.to.be.revertedWithCustomError(this.mock, 'VestingWalletInvalidRevoker')
.withArgs(ethers.ZeroAddress);
});

it('only allows the revoker to revoke', async function () {
await expect(this.mock.connect(this.other).revoke())
.to.be.revertedWithCustomError(this.mock, 'VestingWalletUnauthorizedRevoker')
.withArgs(this.other.address);

await expect(this.mock.connect(this.other).revoke(ethers.Typed.address(this.token)))
.to.be.revertedWithCustomError(this.mock, 'VestingWalletUnauthorizedRevoker')
.withArgs(this.other.address);
});

it('freezes Ether vesting on revoke and keeps the vested portion claimable', async function () {
const quarter = this.start + this.duration / 4n;
const halfway = this.start + this.duration / 2n;

await time.increaseTo.timestamp(quarter, false);
const quarterReleasable = (await this.mock.vestedAmount(quarter)) - (await this.mock.released());
await expect(() => this.mock.release()).to.changeEtherBalances(
[this.mock, this.beneficiary],
[-quarterReleasable, quarterReleasable],
);

await time.increaseTo.timestamp(halfway, false);

const totalAllocation = (await ethers.provider.getBalance(this.mock)) + (await this.mock.released());
const refund = totalAllocation - (await this.mock.vestedAmount(halfway));

await expect(() => this.mock.connect(this.revoker).revoke()).to.changeEtherBalances(
[this.mock, this.revoker],
[-refund, refund],
);

await expect(this.mock.connect(this.revoker).revoke()).to.be.revertedWithCustomError(
this.mock,
'VestingWalletEtherAlreadyRevoked',
);
});

it('does not increase releasable Ether after revocation', async function () {
const halfway = this.start + this.duration / 2n;

await time.increaseTo.timestamp(halfway, false);
const releasableAtHalfway = (await this.mock.vestedAmount(halfway)) - (await this.mock.released());
const refund =
(await ethers.provider.getBalance(this.mock)) +
(await this.mock.released()) -
(await this.mock.vestedAmount(halfway));
await expect(() => this.mock.connect(this.revoker).revoke()).to.changeEtherBalances(
[this.mock, this.revoker],
[-refund, refund],
);

const releasableAtRevocation = await this.mock.releasable();
expect(releasableAtRevocation).to.equal(releasableAtHalfway);
await time.increaseTo.timestamp(this.start + this.duration);
expect(await this.mock.releasable()).to.equal(releasableAtRevocation);

await expect(() => this.mock.release()).to.changeEtherBalances(
[this.mock, this.beneficiary],
[-releasableAtRevocation, releasableAtRevocation],
);

expect(await this.mock.releasable()).to.equal(0n);
});

it('freezes ERC20 vesting on revoke and keeps the vested portion claimable', async function () {
const token = ethers.Typed.address(this.token);
const halfway = this.start + this.duration / 2n;

await time.increaseTo.timestamp(halfway, false);
const totalAllocation = (await this.token.balanceOf(this.mock)) + (await this.mock.released(token));
const refund = totalAllocation - (await this.mock.vestedAmount(token, halfway));
await expect(() => this.mock.connect(this.revoker).revoke(token)).to.changeTokenBalances(
this.token,
[this.mock, this.revoker],
[-refund, refund],
);

const releasableAtRevocation = await this.mock.releasable(token);
await time.increaseTo.timestamp(this.start + this.duration);
expect(await this.mock.releasable(token)).to.equal(releasableAtRevocation);

await expect(() => this.mock.release(token)).to.changeTokenBalances(
this.token,
[this.mock, this.beneficiary],
[-releasableAtRevocation, releasableAtRevocation],
);

expect(await this.mock.releasable(token)).to.equal(0n);
});

it('cannot revoke the same ERC20 twice', async function () {
const token = ethers.Typed.address(this.token);
const halfway = this.start + this.duration / 2n;

await time.increaseTo.timestamp(halfway, false);
await this.mock.connect(this.revoker).revoke(token);

await expect(this.mock.connect(this.revoker).revoke(token))
.to.be.revertedWithCustomError(this.mock, 'VestingWalletERC20AlreadyRevoked')
.withArgs(this.token);
});

it('cannot revoke Ether twice', async function () {
const halfway = this.start + this.duration / 2n;

await time.increaseTo.timestamp(halfway, false);
await this.mock.connect(this.revoker).revoke();

await expect(this.mock.connect(this.revoker).revoke()).to.be.revertedWithCustomError(
this.mock,
'VestingWalletEtherAlreadyRevoked',
);
});
});
Loading