Skip to content

Feature/vesting wallet revocable#6523

Open
tuann72 wants to merge 5 commits into
OpenZeppelin:masterfrom
tuann72:feature/VestingWalletRevocable
Open

Feature/vesting wallet revocable#6523
tuann72 wants to merge 5 commits into
OpenZeppelin:masterfrom
tuann72:feature/VestingWalletRevocable

Conversation

@tuann72
Copy link
Copy Markdown

@tuann72 tuann72 commented May 15, 2026

Issue: #6493

Added a VestingWalletRevocable extension which provides revoking functionalities. The following constraints for revoking include: once a contract has been revoked it cannot be reinstated and revocation returns unvested tokens to owners while vested tokens remain claimable.

PR Checklist

  • Tests
  • Documentation

@tuann72 tuann72 requested a review from a team as a code owner May 15, 2026 23:54
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 15, 2026

⚠️ No Changeset found

Latest commit: d9cee54

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 16, 2026

Review Change Stack

Walkthrough

This PR adds revocation capability to vesting wallets via a new VestingWalletRevocable contract extending OpenZeppelin's VestingWallet. The contract tracks revocation state, snapshots ETH and ERC20 allocations at the revocation timestamp, and freezes vested-amount calculations to prevent further release after revocation. The revoke() function returns all unvested portions to the owner and emits a VestingRevoked event. The test suite verifies access control, prevents double-revocation, validates balance refunds, confirms vesting amount freezes, and tests behavior with multiple tokens.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Feature/vesting wallet revocable' partially describes the main change but uses a branch naming convention rather than a descriptive PR title format, making it vague about the specific feature. Revise the title to be more descriptive and specific, such as 'Add VestingWalletRevocable extension with revocation functionality' or 'Implement revocable vesting wallet with unvested token return capability'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly references Issue #6493, explains the core functionality (revocation with constraints), documents that vested tokens remain claimable while unvested tokens are returned, and notes that tests and documentation are included.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
contracts/finance/VestingWalletRevocable.sol (1)

49-51: 💤 Low value

Handle duplicate tokens in the array.

If the tokens array contains duplicates, the current implementation will:

  1. Snapshot the same token multiple times (line 50), with later iterations overwriting earlier snapshots
  2. Attempt to transfer unvested amounts multiple times (lines 57-61)

The second transfer will typically result in unvested = 0 because the balance was already reduced by the first transfer, so it gracefully avoids double-transfer. However, this wastes gas.

Consider either:

  • Documenting that duplicates are handled but waste gas
  • Adding duplicate detection with a revert for better UX
📝 Example: Document the behavior
     /**
      * `@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.
+     *
+     * Note: Duplicate addresses in `tokens` are handled gracefully but waste gas. Each duplicate will 
+     * re-snapshot and re-check the same token.
      *
      * Emits a {VestingRevoked} event.
      */

Also applies to: 57-61

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/finance/VestingWalletRevocable.sol` around lines 49 - 51, The
tokens array may contain duplicates causing redundant snapshot writes to
_erc20AllocationSnapshot and wasted transfers when computing unvested; update
the logic in VestingWalletRevocable (the loop that sets
_erc20AllocationSnapshot[tokens[i]] = IERC20(tokens[i]).balanceOf(address(this))
+ released(tokens[i]) and the subsequent unvested/release logic) to detect
duplicates: maintain a local memory mapping(address => bool) seen and either
revert with a clear message if a token is seen twice or skip processing
duplicates by continuing when seen[tokens[i]] is true; apply the same duplicate
check in both the snapshot loop and the transfer/unvested loop so duplicate
tokens are never reprocessed.
test/finance/VestingWalletRevocable.test.js (1)

98-107: 💤 Low value

Documentation could clarify that unlisted tokens use frozen timestamp, not progressing time.

The test correctly validates that unlisted tokens have their vesting amount calculated using the current on-chain balance but with the timestamp frozen at revocation. The implementation confirms this: for tokens without a snapshot (unlisted), vestedAmount calls the parent class with _revokedAt rather than the current timestamp.

The documentation at lines 38-39 uses "continue to vest against their on-chain balance at the revocation timestamp," which could be misinterpreted as ongoing vesting. Consider rewording to clarify that vesting is calculated using a frozen timestamp and current balance, not progressing time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/finance/VestingWalletRevocable.test.js` around lines 98 - 107, Update
the documentation text that currently says "continue to vest against their
on-chain balance at the revocation timestamp" to explicitly state that for
unlisted tokens VestingWalletRevocable freezes the timestamp at _revokedAt when
computing vestedAmount (via the parent class) while still using the token's
current on-chain balance for releasable calculations; mention that time does not
progress for vesting calculations after revoke (i.e., vesting is computed with
the frozen revocation timestamp, not a moving current time), and reference the
behavior in vestedAmount and releasable so readers can locate the
implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/finance/VestingWalletRevocable.sol`:
- Around line 54-61: The unvested calculation in revoke() is missing
already-released amounts so owner gets wrong funds when released() > 0; update
the two computations to treat total allocation = current balance + released: set
unvestedEth = address(this).balance + released() -
_vestingSchedule(_ethAllocationSnapshot, t) and for tokens set unvested =
IERC20(tokens[i]).balanceOf(address(this)) + released(tokens[i]) -
_vestingSchedule(_erc20AllocationSnapshot[tokens[i]], t); keep the same transfer
calls (Address.sendValue / safeTransfer) and ensure you use the existing
released()/released(token) functions and _vestingSchedule/_*_snapshot
identifiers so underflow cannot occur.

In `@test/finance/VestingWalletRevocable.test.js`:
- Around line 77-86: Add a test that covers revocation after a partial release
to ensure unvested calculation accounts for already released amounts: simulate
time to 1/4 duration, call release() as the beneficiary, record released amount,
advance to 1/2 duration, call revoke() and assert VestingRevoked event, that
changeEtherBalances/changeTokenBalances transfer only the unvested amount (total
vested at 1/2 minus already released), and finally assert releasable() equals
remaining vested (vested - released); use the existing test pattern and symbols
revoke(), release(), VestingRevoked, releasable() and this.mock/this.token to
implement the checks.

---

Nitpick comments:
In `@contracts/finance/VestingWalletRevocable.sol`:
- Around line 49-51: The tokens array may contain duplicates causing redundant
snapshot writes to _erc20AllocationSnapshot and wasted transfers when computing
unvested; update the logic in VestingWalletRevocable (the loop that sets
_erc20AllocationSnapshot[tokens[i]] = IERC20(tokens[i]).balanceOf(address(this))
+ released(tokens[i]) and the subsequent unvested/release logic) to detect
duplicates: maintain a local memory mapping(address => bool) seen and either
revert with a clear message if a token is seen twice or skip processing
duplicates by continuing when seen[tokens[i]] is true; apply the same duplicate
check in both the snapshot loop and the transfer/unvested loop so duplicate
tokens are never reprocessed.

In `@test/finance/VestingWalletRevocable.test.js`:
- Around line 98-107: Update the documentation text that currently says
"continue to vest against their on-chain balance at the revocation timestamp" to
explicitly state that for unlisted tokens VestingWalletRevocable freezes the
timestamp at _revokedAt when computing vestedAmount (via the parent class) while
still using the token's current on-chain balance for releasable calculations;
mention that time does not progress for vesting calculations after revoke (i.e.,
vesting is computed with the frozen revocation timestamp, not a moving current
time), and reference the behavior in vestedAmount and releasable so readers can
locate the implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0dc6742b-87c8-4434-b70a-b3474d9f1e18

📥 Commits

Reviewing files that changed from the base of the PR and between cd05883 and d9cee54.

📒 Files selected for processing (2)
  • contracts/finance/VestingWalletRevocable.sol
  • test/finance/VestingWalletRevocable.test.js

Comment on lines +54 to +61
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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Critical: Unvested amount calculation is incorrect when tokens have been released before revocation.

The unvested amount calculation on lines 54 and 58-59 is missing a crucial term. The formulas currently calculate:

unvestedEth = balance - vestedAmount
unvestedTokens = balance - vestedAmount

However, the correct formula should be:

unvestedEth = balance - vestedAmount + released()
unvestedTokens = balance - vestedAmount + released(token)

Why this matters:

The total allocation is balance + released. The unvested portion of this total is totalAllocation - vestedAmount = (balance + released) - vestedAmount. The current code omits the + released term.

Impact when released > 0:

Consider this scenario:

  • Total allocation: 100 tokens
  • At t₁ (25% duration): 25 tokens vest, beneficiary releases all 25
  • At t₂ (50% duration): 50 tokens vested total, owner calls revoke()
  • Balance: 75, Released: 25, Vested: 50

Expected: Owner should receive 100 - 50 = 50 unvested tokens
Actual (buggy): Owner receives 75 - 50 = 25 tokens

The owner loses 25 tokens (equal to the pre-revocation released amount), and the beneficiary can claim those 25 unvested tokens.

Why tests don't catch this: All existing tests call revoke() before any release() calls, so released() = 0 and both formulas coincide.

🔧 Proposed fix
-        uint256 unvestedEth = address(this).balance - _vestingSchedule(_ethAllocationSnapshot, t);
+        uint256 unvestedEth = address(this).balance - _vestingSchedule(_ethAllocationSnapshot, t) + released();
         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);
+                _vestingSchedule(_erc20AllocationSnapshot[tokens[i]], t) +
+                released(tokens[i]);
             if (unvested > 0) IERC20(tokens[i]).safeTransfer(owner(), unvested);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/finance/VestingWalletRevocable.sol` around lines 54 - 61, The
unvested calculation in revoke() is missing already-released amounts so owner
gets wrong funds when released() > 0; update the two computations to treat total
allocation = current balance + released: set unvestedEth = address(this).balance
+ released() - _vestingSchedule(_ethAllocationSnapshot, t) and for tokens set
unvested = IERC20(tokens[i]).balanceOf(address(this)) + released(tokens[i]) -
_vestingSchedule(_erc20AllocationSnapshot[tokens[i]], t); keep the same transfer
calls (Address.sendValue / safeTransfer) and ensure you use the existing
released()/released(token) functions and _vestingSchedule/_*_snapshot
identifiers so underflow cannot occur.

Comment on lines +77 to +86
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;
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add test coverage for revocation after partial release.

The current test verifies revocation works when released = 0, but there's no test for the critical scenario where some amount has been released before calling revoke().

This test gap allowed a critical bug in the contract to go undetected (see the contract review comment about incorrect unvested calculation).

🧪 Suggested additional test case
it('returns correct unvested funds after partial release', async function () {
  // Release at 1/4 duration
  await time.increaseTo.timestamp(this.start + this.duration / 4n, false);
  await this.mock.connect(this.beneficiary).release();
  const released = ethers.parseEther('25');
  
  // Revoke at 1/2 duration
  await time.increaseTo.timestamp(this.start + this.duration / 2n, false);
  const tx = await this.mock.connect(this.beneficiary).revoke([this.token.target]);
  
  // At 1/2 duration: vested = 50, released = 25, unvested = 50
  const unvested = ethers.parseEther('50');
  
  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]);
  
  // Beneficiary should only be able to release the remaining vested amount (50 - 25 = 25)
  const releasableEth = await this.mock.releasable();
  expect(releasableEth).to.equal(ethers.parseEther('25'));
});

This test would verify that when some amount has been released before revocation, the owner still receives the correct unvested portion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/finance/VestingWalletRevocable.test.js` around lines 77 - 86, Add a test
that covers revocation after a partial release to ensure unvested calculation
accounts for already released amounts: simulate time to 1/4 duration, call
release() as the beneficiary, record released amount, advance to 1/2 duration,
call revoke() and assert VestingRevoked event, that
changeEtherBalances/changeTokenBalances transfer only the unvested amount (total
vested at 1/2 minus already released), and finally assert releasable() equals
remaining vested (vested - released); use the existing test pattern and symbols
revoke(), release(), VestingRevoked, releasable() and this.mock/this.token to
implement the checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant