From c11d1d577046ab069db783e83ba1c89379b5d643 Mon Sep 17 00:00:00 2001 From: Tuan Nguyen Date: Tue, 5 May 2026 12:03:11 -0500 Subject: [PATCH 1/5] feat: add variables needed for revocation tracking --- contracts/finance/VestingWalletRevocable.sol | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 contracts/finance/VestingWalletRevocable.sol diff --git a/contracts/finance/VestingWalletRevocable.sol b/contracts/finance/VestingWalletRevocable.sol new file mode 100644 index 00000000000..c5537db8390 --- /dev/null +++ b/contracts/finance/VestingWalletRevocable.sol @@ -0,0 +1,16 @@ +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"; + +abstract contract VestingWalletRevocable is VestingWallet { + using SafeERC20 for IERC20; + + bool private _revoked; + uint256 private _ethAllocationSnapshot; + mapping(address token => uint256) private _erc20AllocationSnapshot; + + error AlreadyRevoked(); + event VestingRevoked(address indexed owner); +} From f2b2b970617868915cf0c91cde98e15301e3bf4c Mon Sep 17 00:00:00 2001 From: Tuan Nguyen Date: Thu, 7 May 2026 22:45:48 -0500 Subject: [PATCH 2/5] feat: created sample test for revocable --- contracts/finance/VestingWalletRevocable.sol | 1 + test/finance/VestingWalletRevocable.test.js | 62 ++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 test/finance/VestingWalletRevocable.test.js diff --git a/contracts/finance/VestingWalletRevocable.sol b/contracts/finance/VestingWalletRevocable.sol index c5537db8390..784d65e0302 100644 --- a/contracts/finance/VestingWalletRevocable.sol +++ b/contracts/finance/VestingWalletRevocable.sol @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {VestingWallet} from "./VestingWallet.sol"; diff --git a/test/finance/VestingWalletRevocable.test.js b/test/finance/VestingWalletRevocable.test.js new file mode 100644 index 00000000000..f7b1257ca33 --- /dev/null +++ b/test/finance/VestingWalletRevocable.test.js @@ -0,0 +1,62 @@ +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)); + }); + + describe('inherits VestingWallet', function () { + it('sets beneficiary as owner', async function () { + expect(await this.mock.owner()).to.equal(this.beneficiary.address); + }); + + it('sets start, duration, and end', async function () { + 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); + }); + + 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(); + }); + }); +}); From 0ed2532941c32bb89d75d87db7e9605c4087fb44 Mon Sep 17 00:00:00 2001 From: Tuan Date: Sun, 10 May 2026 23:28:26 -0500 Subject: [PATCH 3/5] feat: completed VestingWalletRevocable abstact contract --- contracts/finance/VestingWalletRevocable.sol | 88 ++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/contracts/finance/VestingWalletRevocable.sol b/contracts/finance/VestingWalletRevocable.sol index 784d65e0302..4b2839ec225 100644 --- a/contracts/finance/VestingWalletRevocable.sol +++ b/contracts/finance/VestingWalletRevocable.sol @@ -1,10 +1,21 @@ // 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"; +/** + * @dev Extension of {VestingWallet} that allows the owner to revoke the vesting schedule. + * + * When revoked, the vesting calculation is frozen at the revocation timestamp, and any unvested + * tokens are returned to the owner. This contract snapshots the total allocation at revocation + * time to ensure the vested amount remains constant post-revocation. + * + * _Available since v5.1._ + */ abstract contract VestingWalletRevocable is VestingWallet { using SafeERC20 for IERC20; @@ -12,6 +23,83 @@ abstract contract VestingWalletRevocable is VestingWallet { 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 Getter for the revocation status. + */ + function isRevoked() public view returns (bool) { + return _revoked; + } + + /** + * @dev Revokes the vesting schedule and returns all unvested tokens to the owner. + * + * Requirements: + * - The vesting schedule must not have been revoked already. + * - Only the owner can call this function. + * + * @param tokens Array of ERC20 token addresses to revoke. ETH is automatically handled. + * + * Emits a {VestingRevoked} event. + */ + function revoke(address[] calldata tokens) external onlyOwner { + // Checks against double revoking + if (_revoked) revert AlreadyRevoked(); + + // Snapshot allocations before any transfers + _etherAllocationSnapshot = 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; + + // Return unvested ETH to owner + uint256 unvestedEth = address(this).balance - vestedAmount(uint64(block.timestamp)); + if (unvestedEth > 0) { + payable(owner()).transfer(unvestedEth); + } + + // Return unvested ERC-20s to owner + for (uint256 i = 0; i < tokens.length; i++) { + uint256 unvested = IERC20(tokens[i]).balanceOf(address(this)) - + vestedAmount(tokens[i], uint64(block.timestamp)); + if (unvested > 0) { + IERC20(tokens[i]).safeTransfer(owner(), unvested); + } + } + + emit VestingRevoked(owner()); + } + + /** + * @dev Overrides the vesting calculation to use the snapshotted allocation after revocation. + * + * If the vesting has been revoked, returns the vested amount based on the allocation at the + * time of revocation. Otherwise, delegates to the parent implementation. + */ + function vestedAmount(uint64 timestamp) public view virtual override returns (uint256) { + if (_revoked) { + return _vestingSchedule(_ethAllocationSnapshot, timestamp); + } + return super.vestedAmount(timestamp); + } + + /** + * @dev Overrides the vesting calculation for ERC20 tokens to use the snapshotted allocation after revocation. + * + * If the vesting has been revoked, returns the vested amount based on the token allocation at the + * time of revocation. Otherwise, delegates to the parent implementation. + */ + function vestedAmount(address token, uint64 timestamp) public view virtual override returns (uint256) { + if (_revoked) { + return _vestingSchedule(_erc20AllocationSnapshot[token], timestamp); + } + return super.vestedAmount(token, timestamp); + } } From 3843bd92e501bd34a14e1bfc4cafdb2d82166824 Mon Sep 17 00:00:00 2001 From: Tuan Nguyen Date: Tue, 12 May 2026 17:06:39 -0500 Subject: [PATCH 4/5] test: added testing for VestingWalletRevocable --- contracts/finance/VestingWalletRevocable.sol | 20 ++++-- test/finance/VestingWalletRevocable.test.js | 75 +++++++++++++++++--- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/contracts/finance/VestingWalletRevocable.sol b/contracts/finance/VestingWalletRevocable.sol index 4b2839ec225..c804d36a241 100644 --- a/contracts/finance/VestingWalletRevocable.sol +++ b/contracts/finance/VestingWalletRevocable.sol @@ -6,6 +6,7 @@ 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 allows the owner to revoke the vesting schedule. @@ -20,6 +21,7 @@ abstract contract VestingWalletRevocable is VestingWallet { using SafeERC20 for IERC20; bool private _revoked; + uint64 private _revocationTimestamp; uint256 private _ethAllocationSnapshot; mapping(address token => uint256) private _erc20AllocationSnapshot; @@ -48,11 +50,11 @@ abstract contract VestingWalletRevocable is VestingWallet { * Emits a {VestingRevoked} event. */ function revoke(address[] calldata tokens) external onlyOwner { - // Checks against double revoking if (_revoked) revert AlreadyRevoked(); - // Snapshot allocations before any transfers - _etherAllocationSnapshot = address(this).balance + released(); + // Snapshot allocations and timestamp before state change + _revocationTimestamp = uint64(block.timestamp); + _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]); } @@ -62,7 +64,7 @@ abstract contract VestingWalletRevocable is VestingWallet { // Return unvested ETH to owner uint256 unvestedEth = address(this).balance - vestedAmount(uint64(block.timestamp)); if (unvestedEth > 0) { - payable(owner()).transfer(unvestedEth); + Address.sendValue(payable(owner()), unvestedEth); } // Return unvested ERC-20s to owner @@ -85,7 +87,7 @@ abstract contract VestingWalletRevocable is VestingWallet { */ function vestedAmount(uint64 timestamp) public view virtual override returns (uint256) { if (_revoked) { - return _vestingSchedule(_ethAllocationSnapshot, timestamp); + return _vestingSchedule(_ethAllocationSnapshot, _revocationTimestamp); } return super.vestedAmount(timestamp); } @@ -95,10 +97,16 @@ abstract contract VestingWalletRevocable is VestingWallet { * * If the vesting has been revoked, returns the vested amount based on the token allocation at the * time of revocation. Otherwise, delegates to the parent implementation. + * For tokens not included in the revoke call, falls back to the parent with the frozen timestamp + * since their balance was not transferred out. */ function vestedAmount(address token, uint64 timestamp) public view virtual override returns (uint256) { if (_revoked) { - return _vestingSchedule(_erc20AllocationSnapshot[token], timestamp); + uint256 snapshot = _erc20AllocationSnapshot[token]; + if (snapshot == 0) { + return super.vestedAmount(token, _revocationTimestamp); + } + return _vestingSchedule(snapshot, _revocationTimestamp); } return super.vestedAmount(token, timestamp); } diff --git a/test/finance/VestingWalletRevocable.test.js b/test/finance/VestingWalletRevocable.test.js index f7b1257ca33..7f4d663bef5 100644 --- a/test/finance/VestingWalletRevocable.test.js +++ b/test/finance/VestingWalletRevocable.test.js @@ -4,6 +4,7 @@ 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() { @@ -12,7 +13,6 @@ async function fixture() { 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']); @@ -32,17 +32,15 @@ describe('VestingWalletRevocable', function () { Object.assign(this, await loadFixture(fixture)); }); - describe('inherits VestingWallet', function () { - it('sets beneficiary as owner', async function () { - expect(await this.mock.owner()).to.equal(this.beneficiary.address); - }); - - it('sets start, duration, and end', async function () { - 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); - }); + 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); @@ -59,4 +57,59 @@ describe('VestingWalletRevocable', function () { 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 ETH and ERC20 to owner and emits event', async function () { + await time.increaseTo.timestamp(this.start + this.duration / 4n, false); + const tx = await this.mock.connect(this.beneficiary).revoke([this.token.target]); + + await expect(tx).to.emit(this.mock, 'VestingRevoked').withArgs(this.beneficiary.address); + await expect(tx).to.changeEtherBalances( + [this.mock, this.beneficiary], + [-ethers.parseEther('75'), ethers.parseEther('75')], + ); + await expect(tx).to.changeTokenBalances( + this.token, + [this.mock, this.beneficiary], + [-ethers.parseEther('75'), ethers.parseEther('75')], + ); + 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 ERC20 claimable at revocation-time vested amount', 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')); + }); + }); }); From d9cee54fae1a116457aca490f1597031f1b3d81a Mon Sep 17 00:00:00 2001 From: Tuan Nguyen Date: Wed, 13 May 2026 16:38:13 -0500 Subject: [PATCH 5/5] style: changed comments/code to be more intuitive --- contracts/finance/VestingWalletRevocable.sol | 73 ++++++-------------- test/finance/VestingWalletRevocable.test.js | 16 ++--- 2 files changed, 25 insertions(+), 64 deletions(-) diff --git a/contracts/finance/VestingWalletRevocable.sol b/contracts/finance/VestingWalletRevocable.sol index c804d36a241..4fee15857f0 100644 --- a/contracts/finance/VestingWalletRevocable.sol +++ b/contracts/finance/VestingWalletRevocable.sol @@ -9,11 +9,8 @@ import {IERC20} from "../token/ERC20/IERC20.sol"; import {Address} from "../utils/Address.sol"; /** - * @dev Extension of {VestingWallet} that allows the owner to revoke the vesting schedule. - * - * When revoked, the vesting calculation is frozen at the revocation timestamp, and any unvested - * tokens are returned to the owner. This contract snapshots the total allocation at revocation - * time to ensure the vested amount remains constant post-revocation. + * @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._ */ @@ -21,7 +18,7 @@ abstract contract VestingWalletRevocable is VestingWallet { using SafeERC20 for IERC20; bool private _revoked; - uint64 private _revocationTimestamp; + uint64 private _revokedAt; uint256 private _ethAllocationSnapshot; mapping(address token => uint256) private _erc20AllocationSnapshot; @@ -31,83 +28,53 @@ abstract contract VestingWalletRevocable is VestingWallet { /// @dev Emitted when the vesting schedule is revoked. event VestingRevoked(address indexed owner); - /** - * @dev Getter for the revocation status. - */ + /// @dev Whether the vesting schedule has been revoked. function isRevoked() public view returns (bool) { return _revoked; } /** - * @dev Revokes the vesting schedule and returns all unvested tokens to the owner. - * - * Requirements: - * - The vesting schedule must not have been revoked already. - * - Only the owner can call this function. - * - * @param tokens Array of ERC20 token addresses to revoke. ETH is automatically handled. + * @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(); - // Snapshot allocations and timestamp before state change - _revocationTimestamp = uint64(block.timestamp); + 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; - // Return unvested ETH to owner - uint256 unvestedEth = address(this).balance - vestedAmount(uint64(block.timestamp)); - if (unvestedEth > 0) { - Address.sendValue(payable(owner()), unvestedEth); - } + uint256 unvestedEth = address(this).balance - _vestingSchedule(_ethAllocationSnapshot, t); + if (unvestedEth > 0) Address.sendValue(payable(owner()), unvestedEth); - // Return unvested ERC-20s to owner for (uint256 i = 0; i < tokens.length; i++) { uint256 unvested = IERC20(tokens[i]).balanceOf(address(this)) - - vestedAmount(tokens[i], uint64(block.timestamp)); - if (unvested > 0) { - IERC20(tokens[i]).safeTransfer(owner(), unvested); - } + _vestingSchedule(_erc20AllocationSnapshot[tokens[i]], t); + if (unvested > 0) IERC20(tokens[i]).safeTransfer(owner(), unvested); } emit VestingRevoked(owner()); } - /** - * @dev Overrides the vesting calculation to use the snapshotted allocation after revocation. - * - * If the vesting has been revoked, returns the vested amount based on the allocation at the - * time of revocation. Otherwise, delegates to the parent implementation. - */ + /// @dev After revocation, returns the vested amount frozen at the revocation timestamp. function vestedAmount(uint64 timestamp) public view virtual override returns (uint256) { - if (_revoked) { - return _vestingSchedule(_ethAllocationSnapshot, _revocationTimestamp); - } - return super.vestedAmount(timestamp); + return _revoked ? _vestingSchedule(_ethAllocationSnapshot, _revokedAt) : super.vestedAmount(timestamp); } /** - * @dev Overrides the vesting calculation for ERC20 tokens to use the snapshotted allocation after revocation. - * - * If the vesting has been revoked, returns the vested amount based on the token allocation at the - * time of revocation. Otherwise, delegates to the parent implementation. - * For tokens not included in the revoke call, falls back to the parent with the frozen timestamp - * since their balance was not transferred out. + * @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) { - uint256 snapshot = _erc20AllocationSnapshot[token]; - if (snapshot == 0) { - return super.vestedAmount(token, _revocationTimestamp); - } - return _vestingSchedule(snapshot, _revocationTimestamp); - } - return super.vestedAmount(token, timestamp); + 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 index 7f4d663bef5..eadc479cf3d 100644 --- a/test/finance/VestingWalletRevocable.test.js +++ b/test/finance/VestingWalletRevocable.test.js @@ -74,20 +74,14 @@ describe('VestingWalletRevocable', function () { ); }); - it('returns unvested ETH and ERC20 to owner and emits event', async function () { + 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], - [-ethers.parseEther('75'), ethers.parseEther('75')], - ); - await expect(tx).to.changeTokenBalances( - this.token, - [this.mock, this.beneficiary], - [-ethers.parseEther('75'), ethers.parseEther('75')], - ); + 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; }); @@ -101,7 +95,7 @@ describe('VestingWalletRevocable', function () { expect(await this.mock.vestedAmount(this.start + this.duration)).to.equal(vestedAtRevoke); }); - it('keeps un-listed ERC20 claimable at revocation-time vested amount', async function () { + 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'));