News | OpenZeppelin

Token Operations FHE Contracts Audit

Written by OpenZeppelin Security | May 7, 2026

Summary

Type: Blockchain Infrastructure
Timeline: From 2026-03-16 → To 2026-04-02
Languages: Solidity

Findings
Total issues: 18 (16 resolved, 1 partially resolved)
Critical: 0 (0 resolved) · High: 0 (0 resolved) · Medium: 2 (2 resolved) · Low: 7 (7 resolved)

Notes & Additional Information
7 notes raised (5 resolved, 1 partially resolved)

Client Reported Issues
2 reported issues (2 resolved)

Scope

OpenZeppelin performed an audit of two repositories:

  • The VestingLabs/tokenops-fhe-disperse-v1 repository at the 968dd47 commit
  • The VestingLabs/tokenops-fhe-vesting-v2 repository at the a0a3c78 commit

In scope were the following files:

 tokenops-fhe-disperse-v1/contracts
├── DisperseConfidential.sol
├── interfaces
│   ├── IArbSys.sol
│   └── IDisperseConfidential.sol
├── library
│   └── Errors.sol
└── wallet
    └── DisperseWallet.sol

tokenops-fhe-vesting-v2/contracts
├── factory
│   └── ConfidentialVestingFactory.sol
├── interfaces
│   ├── IArbSys.sol
│   ├── IConfidentialVestingFactory.sol
│   ├── IConfidentialVestingManager.sol
│   └── IConfidentialVestingManagerTypes.sol
└── vesting
    ├── ConfidentialVestingManager.sol
    ├── ConfidentialVestingManagerBase.sol
    ├── ConfidentialVestingManagerExtension.sol
    └── ConfidentialVestingManagerStorage.sol

During the fix-review phase, the client provided several commits on the audit-submission branches that touch in-scope contract code but were not tied to a finding identifier. These were attended to as part of the final review, and no security issues were identified. The commits are listed below for completeness:

Repository Commit Description
tokenops-fhe-vesting-v2 3b51424 Collapse isRecipient and recipientIndex into a single packed RecipientSlot mapping.
tokenops-fhe-vesting-v2 13116b2 Add an immutable splitVesting toggle set at clone-deployment time via packed clone arguments.
tokenops-fhe-vesting-v2 184168c Introduce adminGetTokenBalance, a DISCLOSURE_ADMIN_ROLE-gated read of the manager's encrypted ERC-7984 balance.
tokenops-fhe-vesting-v2 940645f Migrate the role base from AccessControlUpgradeable to AccessControlEnumerableUpgradeable.
tokenops-fhe-disperse-v1 87771f7 Add the accessEncryptedFeeReserve role-gated reserve accessor and the discloseHandleToParty / batchDiscloseHandlesToParty self-service disclosure primitive.

System Overview

The engagement covers two independent Solidity systems built on Zama's FHEVM. They share the same core goal: enabling token operations where individual amounts remain encrypted on-chain using fully homomorphic encryption (FHE) via the ERC-7984 confidential token standard.

DisperseConfidential

DisperseConfidential is a singleton contract that enables distributing encrypted ERC-7984 token amounts to multiple recipients in a single transaction, without revealing individual allocations on-chain. It supports three disperse modes that differ in fee model and token-holding strategy:

  • Wallet-mode with gas fee distributes tokens through per-user ERC-1167 wallet clones. The caller provides pre-computed encrypted subtotals for two wallet groups, which are trusted without on-chain verification because calculating them may exceed the FHEVM computation depth limit. A fixed ETH fee is charged per recipient.
  • Wallet-mode with token fee follows the same wallet-based flow but deducts a percentage (in basis points) from the total dispersed amount as a fee, collected in the distribution token itself.
  • Direct mode skips wallets entirely and calls confidentialTransferFrom once per recipient directly from the sender's balance, charging only an ETH gas fee.

Each user must register once to deploy a pair of deterministic wallet clones (ERC-1167 minimal proxies pointing to DisperseWallet). Wallets hold tokens temporarily during a disperse and grant the controller (DisperseConfidential) unlimited operator approval. Users can recover residual tokens from their wallets if subtotals were incorrect.

The contract uses OpenZeppelin's AccessControl for role-based permissions, Pausable for emergency circuit-breaking, and ReentrancyGuardTransient for reentrancy protection. Fee configuration is managed through a packed single-slot struct supporting both global defaults and per-user custom overrides.

ConfidentialVesting

The confidential vesting system manages multi-recipient, multi-schedule token vesting with encrypted allocation amounts. It follows a three-layer deployment architecture:

  • ConfidentialVestingFactory deploys vesting manager clones via EIP-1167 minimal proxies with immutable arguments (token address, fee type, fee amount, and deployment block number) packed into the clone bytecode. It uses deterministic addressing with frontrun-protected salts derived from the deployer address and manages fee configuration (defaults and per-creator overrides).
  • ConfidentialVestingManager is the implementation contract behind each clone. It handles hot-path operations: initialization, single and batch vesting creation, and claiming. Tokens are pulled from the creator at vesting creation time via confidentialTransferFrom.
  • ConfidentialVestingManagerExtension handles cold-path operations (view functions, revocation, admin withdrawals, ownership transfers, fee management, and encrypted amount disclosure) via DELEGATECALL from the main contract's fallback. This split keeps the main contract under the 24 KB EVM size limit.

Each vesting schedule supports configurable start and end times, cliff periods, discrete release intervals, timelocks, and initial/cliff unlock percentages in basis points. Schedules can optionally be marked as revocable. The system supports two fee models: a fixed ETH gas fee per claim, or a percentage deducted from each claim amount in the distribution token. Vesting positions can be transferred directly or via a two-step accept pattern. Vesting positions can also be split into proportional sub-positions.

Security Model and Trust Assumptions

Both systems operate on the FHEVM, which introduces a fundamentally different failure model compared to standard EVM contracts. Encrypted computation results cannot be inspected or branched on in cleartext, meaning traditional revert-on-failure patterns are unavailable for amount validation. The security model is shaped by this constraint.

  • FHE coprocessor integrity. Both systems assume the FHEVM coprocessor correctly executes homomorphic operations and that the ACL system faithfully enforces decryption permissions. A compromised coprocessor could produce incorrect encrypted results or leak plaintext values.
  • Caller-provided subtotals (DisperseConfidential). The wallet-mode disperse functions trust that the caller provides correct encrypted subtotals for each wallet group. On-chain verification is infeasible due to the 5M HCU depth limit. Incorrect subtotals do not cause reverts but produce silent failures: deflated subtotals cause some recipients to receive zero, while inflated subtotals leave excess tokens stranded in wallets. The system relies on post-transaction event monitoring to detect these failures.
  • Silent transfer failures. ERC-7984's confidentialTransferFrom returns an encrypted zero on insufficient balance rather than reverting. Both systems handle this through design-level mitigations (the vesting system uses an all-or-nothing FHE.select pattern for batch creates; the disperse system emits verifiable event handles), but the absence of on-chain revert means off-chain monitoring is a required component of the security model.
  • ACL immutability. The FHEVM ACL is append-only with no revocation mechanism. Once an address is granted decryption access to an encrypted handle, that access is permanent. This affects the vesting transfer flow, where a previous recipient retains the ability to decrypt handles they were granted access to before the transfer.
  • Token trust. Both systems assume the configured ERC-7984 token is well-behaved. Fee-on-transfer, rebase, and malicious callback tokens are explicitly unsupported. The token address is immutable in both systems (constructor-set in DisperseConfidential, bytecode-baked in vesting clones), preventing post-deployment substitution.
  • Privileged roles. Both systems assume that privileged actors are non-malicious and work in the interest of the users.
  • Arbitrum L2 block numbers. Both systems include Arbitrum-aware block number resolution via the ArbSys precompile at address 0x64. The deployment block number is recorded for event indexing purposes. This assumes the precompile behaves correctly on supported chains and that block.number is acceptable on non-Arbitrum deployments.

Privileged Roles

Each entry lists the role name followed by the functions it can call.

DisperseConfidential

  • DEFAULT_ADMIN_ROLEsetMaxBatchSizeHolding, setMaxBatchSizeDirect, setMaxBatchSizeTokenFee, grantRole, revokeRole
  • PAUSER_ROLEpause, unpause
  • WITHDRAWER_ROLErescueConfidentialTokens, rescueERC20
  • FEE_MANAGER_ROLEsetFeeConfig, setCustomFee, disableCustomFee
  • FEE_COLLECTOR_ROLEwithdrawGasFee, withdrawTokenFee

ConfidentialVestingFactory

  • DEFAULT_ADMIN_ROLEgrantRole, revokeRole
  • FEE_MANAGER_ROLEsetDefaultGasFee, setDefaultTokenFee, setDefaultFeeType, resetGasFee, resetTokenFee, setCustomFee, disableCustomFee, setFeeCollector

ConfidentialVestingManager (per-clone)

  • DEFAULT_ADMIN_ROLEsetMaxBatchSize, setMaxRevokeBatchSize, grantRole, revokeRole
  • VESTING_CREATOR_ROLEcreateVesting, batchCreateVesting
  • REVOKER_ROLErevokeVesting, batchRevokeVesting
  • WITHDRAWER_ROLEwithdrawAdmin, withdrawOtherToken, withdrawOtherConfidentialToken
  • CLAIMER_ROLEadminClaim, adminPartialClaim
  • FEE_COLLECTOR_ROLE (self-administered) — withdrawGasFee, withdrawTokenFee, transferFeeCollectorRole
  • DISCLOSURE_ADMIN_ROLEadminGetVestedAmount, adminGetClaimableAmount, adminGetTotalAllocation, adminGetSettledAmount, adminDiscloseToParty, batchAdminDiscloseToParty
  • PAUSER_ROLEpause, unpause
 

Medium Severity

Fee Evasion Through Wallet Prefunding With Zero Subtotals

The disperseConfidentialTokensWithTokenFee function computes fees based on two user-supplied encrypted subtotals that are meant to represent the total value being dispersed, split across two wallets. However, the function does not validate that the sum of the individual encrypted amounts matches the sum of the two subtotals. This lack of validation allows a caller to supply zero-valued subtotals while providing legitimate per-recipient amounts, effectively bypassing the token fee.

The attack operates as follows:

  1. A user registers and obtains two wallet clones.

  2. The user transfers confidential tokens directly to the wallet addresses through standard confidentialTransfer calls, prefunding them with the amounts needed for distribution.

  3. The user calls disperseConfidentialTokensWithTokenFee with legitimate per-recipient encryptedAmounts but sets both encryptedSubtotals to encrypted zero.

  4. In _collectTokenFeeOnTotal, the fee is computed on the sum of the subtotals (zero), resulting in a zero fee. The fee pull transfers zero from the caller, and feeSuccess evaluates to true since the transferred amount equals the requested fee (both zero).

  5. The post-fee subtotals (still zero) are transferred to the wallets, which has no effect.

  6. _distributeFromWallets transfers the actual per-recipient amounts from the prefunded wallets to recipients, succeeding against the existing balance.

Nothing prevents direct token transfers to the wallet clone addresses since they are ordinary contracts with operator approval for the DisperseConfidential contract. The caller knows the plaintext values behind every encrypted amount (as they encrypted them), making it straightforward to compute the correct prefunding totals for each wallet.

It is recommended to sweep the wallets before transferring the subtotals to them to prevent using previously transferred funds and evading fees.

Update: Resolved in pull request #23 at commit 5508f24.

Split Rounding Causes Over-Release of Reserves on Revocation

The ConfidentialVestingManagerExtension keeps track of outstanding vesting obligations through enc.numTokensReservedForVesting, which decreases on claims and on revocations. Separately, splitVesting intentionally allows a child position to temporarily have settledAmount > vestedAmount due to independent floor division of allocation and settled amounts in _computeSplitAmounts. This discrepancy is already accounted for in the claim path, where _calculateClaimable guards claimable with an FHE.select. However, the revocation path does not apply the same guard.

In _batchRevoke, the unvested amount for each schedule is computed as totalAllocation - vested(now). When settledAmount > vestedAmount on a split child, the remaining reserve actually attributable to that vesting is totalAllocation - settledAmount, which is strictly less than totalAllocation - vestedAmount. The function therefore releases more tokens from the global reserve than the vesting actually holds.

This leads to two distinct failure modes depending on the size of the over-release relative to the current global reserve:

  1. Over-release without underflow. When the global reserve is large enough to absorb the excess subtraction, the encrypted subtraction in _subtractFromEncryptedReserve does not wrap. The reserve counter is simply reduced by more than the revoked vesting's fair share. The difference is effectively stolen from other active vestings' reserved balance, making it withdrawable by the admin through withdrawAdmin. Recipients of those other vestings may later be unable to claim their full entitlement because the tokens backing their schedules have been withdrawn.

  2. Underflow and reserve corruption. When the over-released amount exceeds the current global reserve, FHE.sub in _subtractFromEncryptedReserve wraps the encrypted value. This corrupts numTokensReservedForVesting to a near-maximum value. As a result, _getAvailableBalance returns zero for all subsequent calls because reserved will always exceed balance. This permanently locks the admin out of withdrawals and may also interfere with any future reserve accounting.

A concrete sequence that triggers the issue:

  1. A recipient claims part of their vesting, so settledAmount > 0.

  2. The recipient calls splitVesting, and one child ends up with settledAmount > vestedAmount due to rounding in _computeSplitAmounts.

  3. A revoker calls batchRevokeVesting on that child.

  4. _batchRevoke computes unvested = totalAllocation - vested, which is larger than totalAllocation - settled, and subtracts that inflated value from the global reserve.

Consider computing the revoked releasable amount as totalAllocation - max(vestedAmount, settledAmount) (e.g., via FHE.select(FHE.ge(vested, settled), vested, settled)) so that the subtraction never exceeds the vesting's actual remaining reserve. Consider also hardening _subtractFromEncryptedReserve with an encrypted FHE.ge check that caps the subtraction at currentReserve on underflow to prevent reserve corruption if a caller violates an invariant elsewhere.

Update: Resolved in pull request #43 at commit b3fc0a1, 3267501, 2a4819d, dc7ea15 and at commit 0690b30.

Low Severity

Discarded Transfer Results in Direct Disperse Mode

The disperseConfidentialTokenDirect function calls confidentialTransferFrom without capturing its return value. In fhEVM, confidentialTransferFrom silently returns zero on insufficient balance rather than reverting. Because the result is discarded, there is no record of which transfers succeeded and which silently failed. The wallet-based disperse paths (disperseConfidentialTokens, disperseConfidentialTokensWithTokenFee) correctly capture the return value and emit it in the WalletDistribution event, but the direct path lacks an equivalent mechanism.

Consider emitting a distribution event (similar to WalletDistribution) so that callers can verify transfer outcomes post-transaction.

Update: Resolved in pull request #23 at commit 582cb92.

getClaimableAmount Reports Non-Zero Claimable During Timelock Period

The getClaimableAmount function computes the claimable amount by calling _resolveEncryptedAmount with DisclosureType.ClaimableAmount. This path calculates the vested amount at block.timestamp, subtracts the settled amount, and returns the difference. However, it does not check whether the timelock period (startTimestamp + timelock) has elapsed. In contrast, the actual _calculateClaimable function used by claim and partialClaim enforces the timelock and reverts with ClaimLocked() if called too early.

As a result, a recipient calling getClaimableAmount during the timelock window receives a non-zero encrypted value suggesting tokens are available, while any attempt to claim those tokens will revert. Frontends or off-chain integrations relying on this function to determine claimability would present inaccurate information to users.

Consider adding a timelock check in the ClaimableAmount branch of _resolveEncryptedAmount, returning an encrypted zero when block.timestamp < startTimestamp + timelock, to align its output with the actual claim logic.

Update: Resolved in pull request #43 at commit 7fb5a98.

Fee Reserve Desynchronization on Partial Confidential Transfer

Token fees accumulated in the encrypted _tokenFeeReserved mapping are withdrawn by the fee collector through withdrawTokenFee. ERC-7984 compliant tokens return the encrypted amount actually transferred from confidentialTransfer, which can be smaller than the requested amount without reverting.

The withdrawal function subtracts the full withdrawAmount from the reserve before initiating the transfer, then delegates to _sendConfidentialTokens, which discards the returned euint64 transferred value. When a partial transfer occurs, the following sequence leads to desynchronization:

  1. The fee collector calls the withdrawal function for the full reserved amount X.

  2. The reserve is decremented by X.

  3. The confidential transfer executes and returns transferred = 0 without reverting.

  4. The difference is no longer tracked in the reserve but remains in the contract, stranding those tokens permanently.

The emitted TokenFeeWithdrawn event also overstates the amount actually transferred, since it logs the requested amount rather than the transferred amount.

Consider reverting when the transferred amount does not match the requested amount.

Update: Resolved in pull request #43 at commit f86b333 and at commit d7801ad. The team stated:

Instead of reverting, both repos now apply a non-reverting pattern that preserves the finding's underlying invariant.

Post-Revocation splitVesting Can Permanently Over-Reserve Due to Per-Position Floor Rounding

In the ConfidentialVestingManagerExtension contract's splitVesting function, revocation is implemented by recording the revokeTimestamp and releasing reserves as unvested = total - vested in _batchRevoke. After revocation, vesting math is "frozen" by capping the effective timestamp to revokeTimestamp in _calculateVestedAmountFromParams, while the schedule continues to store only timing fields (VestingSchedule).

However, splitVesting remains callable after revocation and mutates the parent’s vesting and settled amounts in-place via _computeSplitAmounts, while the child inherits the same revokeTimestamp via _copyVestingSchedule. Since vested-at-revoke is recomputed per position using truncating division in _calculateVestedAfterCliff, the sum of child vested-at-revoke can be strictly less than the pre-split parent vested-at-revoke. The global reserve is not reconciled: reserve is decremented only by the actual claim amount in _transferClaimAmount, and terminal cleanup deletes storage without adjusting reserves in _cleanupCompletedVesting. This can permanently overstate enc.numTokensReservedForVesting, reducing withdrawAdmin availability through _getAvailableBalance while the recipient cannot claim the over-reserved dust.

Consider disallowing splitVesting when revokeTimestamp != 0, or snapshotting and storing the vested-at-revoke amount during _batchRevoke and using that snapshot for both post-revoke claimability and reserve accounting. Consider additionally releasing any leftover reserve for revoked vestings during terminal cleanup.

Update: Resolved in pull request #43 at commit a1ac5fe. The team stated:

splitVesting is now rejected outright when revokeTimestamp != 0. Secondary sub-recommendation rendered unnecessary by unrelated fixes.

FEE_COLLECTOR_ROLE Can Be Emptied, Permanently Disabling Fee Withdrawals

In the ConfidentialVestingManagerExtension contract's transferFeeCollectorRole function, protocol fee withdrawals are gated by FEE_COLLECTOR_ROLE. During initialization, the role is made self-administered and is granted to a single feeCollector, so a “single fee collector” state is immediately reachable.

transferFeeCollectorRole allows newCollector == msg.sender, which makes the “grant” step a no-op and then revokes the caller. If the caller is the only fee collector, FEE_COLLECTOR_ROLE becomes empty in a single successful transaction. Since the role is self-administered, no account can satisfy the admin check to restore it, and fee withdrawals become unreachable. Token fee reserves are purposefully not recoverable through withdrawAdmin.

Consider adding an explicit newCollector != msg.sender check in transferFeeCollectorRole to prevent the FEE_COLLECTOR_ROLE from becoming empty.

Update: Resolved in pull request #43 at commit 44e8574.

Reentrant Call Can Desync Recipient Indices

In the ConfidentialVestingManager contract's _claim function, when a claim reaches its terminal state, the token transfer occurs via _transferClaimAmount and then calls _cleanupCompletedVesting using the recipient value cached before the external call. Recipient tracking relies on recipientVestings and allRecipients, maintained by swap-and-pop in _removeVestingFromRecipient and by updates in _executeVestingTransfer.

Because the external IERC7984(token).confidentialTransfer occurs before terminal cleanup, and claim is not nonReentrant, a token implementation with callbacks can reenter the manager during the transfer and can reach extension functions that mutate recipient ownership (e.g., acceptVestingTransfer or directVestingTransfer). This can update vestingIdentity[vestingId].recipient and indexInRecipientArray before the outer call reaches cleanup. Cleanup then calls _removeVestingFromRecipient(vestingId, oldRecipient) but _removeVestingFromRecipient reads indexInRecipientArray from the (now updated) identity and does not verify that recipientVestings[oldRecipient][index] == vestingId, which can remove the wrong entry, corrupt indices for unrelated vestings, or revert due to out-of-bounds.

Consider reducing the reentrancy surface by marking claim paths and ownership-transfer paths as nonReentrant, and/or performing terminal cleanup before the token transfer so the vesting cannot be moved mid-claim. Consider also hardening _removeVestingFromRecipient by validating bounds and membership (and reverting with a dedicated error) before performing swap-and-pop, to prevent silent corruption when a recipient/index mismatch occurs.

Update: Resolved in pull request #43 at commit bf14eac.

Claims Can Be Indefinitely Delayed by Pauser

The ConfidentialVestingManager allows the PAUSER_ROLE to pause calls to claim and partialClaim to provide an emergency stop for vesting operations. This creates a significant trust assumption for the recipient, as the admin-controlled pauser may potentially alter the vesting terms. This is unusual for the trustless escrow-like functionality normally expected from vesting-type contracts.

The intended use of the vesting contracts between known and/or trusted parties suggests this trade-off may be acceptable. However, it is worth considering adding a mechanism that allows a deployed vesting contract to operate in a trust-minimized manner, in order to support a wider variety of use cases.

Update: Resolved in pull request #43 at commit 4ba5f81 and at commit 1d4aa1a. The team stated:

A per-clone PAUSABLE_ENABLED immutable bit was added so deployers can opt out of pause (and of PAUSER_ROLE ever being granted) at clone creation, giving recipients a verifiable trust-minimized escrow mode without removing pause for deployments that want it._

Notes & Additional Information

Redundant ACL Grant on Transfer Results in Wallet Distribution

In _distributeFromSingleWallet, after each confidentialTransferFrom call, persistent ACL is granted on the result handle to the recipient. However, the ERC-7984 token's internal _update function already grants FHE.allow(transferred, to) for every transfer, where to is the recipient and transferred is the same handle returned as result. This makes the explicit FHE.allow(result, recipients[idx]) call redundant, as the recipient already holds persistent ACL on that handle by the time execution returns to the disperse contract.

Consider removing the redundant FHE.allow(result, recipients[idx]) call to save gas and reduce unnecessary FHE ACL operations per recipient.

Update: Resolved in pull request #23 at commit 97d2734.

Rounding Favors Recipient

In the ConfidentialVestingManager contract's _transferClaimAmount, the fee is rounded down, which favors the claimant. Fees are charged per claim and can therefore be reduced by fragmenting a large claim into many calls to partialClaim. While not practical to exploit in this case, favoring the protocol when rounding is generally preferred to harden business logic.

If the protocol’s intent is that fee revenue should not depend on claim granularity, then rounding should not favor the user. Consider rounding fees up instead of down and apply this consistently to both the deducted fee and the fee reserve accounting.

Update: Resolved in pull request #43 at commit a2c0782 and at commit b663b3a.

withdrawTokenFee Allows Burning of Fees

In the ConfidentialVestingManagerExtension contract, token fee withdrawals are performed by withdrawTokenFee. This function is restricted to FEE_COLLECTOR_ROLE and transfers the capped amount via _sendConfidentialTokens. Unlike withdrawGasFee and _validateOtherTokenWithdraw, this path does not enforce to != address(0).

If the configured IERC7984 token permits confidentialTransfer(address(0), amount) (burn/lock semantics), withdrawTokenFee can irreversibly destroy accumulated protocol fees through operator error or fee collector compromise. If the token instead reverts on to == address(0), the withdrawal reverts and fees are not lost, but the manager still relies on token-layer validation and behaves inconsistently with other withdrawal paths.

Consider adding to == address(0) validation to withdrawTokenFee (or centrally in _sendConfidentialTokens) to match other withdrawal paths and prevent operator error.

Update: Resolved in pull request #43 at commit 7e69f61.

Rounding Can Make a Settled Position Immediately Claimable After Splitting

In the ConfidentialVestingManagerExtension contract, the splitVesting function proportionally splits both the vested amount and the settled amount in _computeSplitAmounts using FHE.div(FHE.mul(x, N), D) and assigns the remainder to the child via subtraction. Separately, vested is computed using truncating division in _calculateVestedAmountFromParams.

Because the splits and the vesting math floor independently, it is possible for a parent position to be fully settled at time T (so claimable == 0), while one of the children becomes temporarily under-settled after the split (vested(child, T) > settled(child)). The claim path computes claimable = max(vested - settled, 0) per vesting ID in _calculateClaimable and therefore allows claiming the rounding "dust" immediately. Repeating split-and-claim can accumulate early release in the smallest units, weakening the intended vesting curve guarantees (while remaining bounded by per-split rounding).

Consider adjusting the settledAmount split logic to prevent the creation of under-settled children at block.timestamp (for example, by allocating the rounding remainder to settledAmount such that each child satisfies settled(child) >= vested(child, now), or by rounding the settledAmount split conservatively). Consider documenting the expected rounding behavior if the early release of dust is acceptable by design.

Update: Acknowledged, not resolved. The team stated:

Acknowledged. Accepted as a bounded, known trade-off — no code change.

Events Emitted Inside Loops

Within ConfidentialVestingManagerExtension.sol, several events are emitted inside loops:

The same event emission pattern is noted in DisperseConfidential:

The implementation semantics across the hot-path functions in ConfidentialVestingManager consistently use arrays as input types for internal business logic functions. Consider modifying the events to emit a single array instead of multiple events, as this is internally consistent and improves gas efficiency.

Update: Partially Resolved in pull request #23 at commit 081f9c6. The team stated:

Disperse (tokenops-fhe-disperse-v1) shipped the recommended batched-emit refactor; vesting (tokenops-fhe-vesting-v2) accepts the per-iteration emit pattern as a deliberate design choice.

Use Custom Errors

Since Solidity version 0.8.4, custom errors provide a cleaner and more cost-efficient way to explain to users why an operation failed.

The codebase uses custom errors for the majority of reverts, but it contains one inconsistent string-style error message: revert("No ETH accepted").

For consistency, consider replacing the string-style revert with a custom error.

Update: Resolved in pull request #43 at commit 693c94b.

Decrypt Access Not Granted to Caller of withdrawTokenFee

The manager emits encrypted withdrawal amounts in the TokenFeeWithdrawal event to allow off-chain monitoring of the effective withdrawn amount. The withdrawTokenFee function caps the requested amount via FHE.min, producing a fresh ciphertext handle withdrawAmount.

However, withdrawAmount is never granted persistent decrypt ACL to msg.sender when it is not the recipient. The transfer helper _sendConfidentialTokens only calls FHE.allowTransient(amount, token) to authorize the token contract to consume the ciphertext during confidentialTransfer, which does not grant later decrypt access to msg.sender.

Consider granting persistent ACL for the msg.sender on withdrawAmount when calling withdrawTokenFee before emitting the event when to != msg.sender.

Update: Resolved in pull request #43 at commit 32e05ff and at commit 8ace5d3.

Client Reported

Terminal Claim Cleanup Runs on Silently Failed Confidential Transfer

When a recipient's terminal claim() is processed, the manager updates settledAmount additively, calls _transferClaimAmount (which subtracts from numTokensReservedForVesting and invokes IERC7984.confidentialTransfer), and then runs _cleanupCompletedVesting unconditionally on isTerminal. The cleanup deletes the schedule, the encrypted amounts, and the recipient's index entry, leaving no on-chain trace of the obligation.

ERC-7984's confidentialTransfer does not revert when the sender's encrypted balance is insufficient: it uses tryDecrease semantics and silently returns an encrypted zero. When the underlying reserve has been corrupted upstream — for example by the over-release described in finding 'Split Rounding Causes Over-Release of Reserves on Revocation' — a recipient's terminal claim therefore proceeds along three observable steps without ever moving tokens:

  1. settledAmount is incremented additively (settled + claimable), so the schedule's accounting records the full claim as paid.
  2. numTokensReservedForVesting is decremented (or wraps, if it was already underwater) by the same claimAmount.
  3. _cleanupCompletedVesting deletes vestingIdentity[vestingId], vestingSchedule[vestingId], pendingTransfers[vestingId], vestingAmount[vestingId], and settledAmount[vestingId], and removes the recipient from allRecipients if no other vestings remain.

The recipient has no retry path. The vesting record they would have used to re-claim no longer exists, the VestingCompleted event has been emitted, and the encrypted token transfer cannot be replayed because nothing on-chain reflects the failure. A reserve drift of as little as one unit on a single child position is enough to mask a full allocation loss for the affected recipient if their terminal claim is the call that pushes the global reserve through zero. Because the FHE reserve counter is not exposed via an admin ACL, off-chain monitoring cannot rely on direct inspection of the corrupted state.

The same cleanup also fires under the partial-success case: when the reserve is large enough to fund part of a claim but not all of it, the silent zero collapses the claim entirely (the transfer cannot be partial under tryDecrease), and the schedule is still deleted as if a full claim had been settled.

Consider gating cleanup on confirmation that the transfer actually moved the requested amount. Concretely, capture the encrypted return handle from IERC7984.confidentialTransfer (currently discarded), thread it back through _transferClaimAmount, and use it to derive the effective settled increment via FHE.select(FHE.ge(transferred, claimable), claimable, FHE.asEuint128(0)) (and similarly cap the reserve decrement). Cleanup should then either be conditioned on the same comparison or moved out of the claim path into a separate, explicitly gated call that the recipient or admin can invoke once the transfer is known to have succeeded. The first option requires the claim path to break CEI, which can be mitigated with a nonReentrant guard on the claim entry points; the second option avoids the CEI concern entirely at the cost of an additional transaction in the happy path.

Update: Resolved in pull request #43 at commit 0690b30.

Disclosure and Event Handles Are Not Decryptable Across Transactions

The ConfidentialVestingManager and ConfidentialVestingManagerExtension contracts surface encrypted amounts to off-chain consumers through two channels: the encrypted getter / disclosure functions, and the TokensClaimed and AdminWithdrawal events. In both channels, several call sites publish handles that carry only transient FHE access control, which prevents the intended viewer from decrypting them across transaction boundaries.

The off-chain userDecrypt flow requires the decryption target to hold persistent FHE ACL on both the contract that produced the handle and the address attempting to decrypt. Handles produced by FHE operations carry only transient ACL for the producer contract, scoped to the producing transaction. A subsequent FHE.allow(handle, viewer) grants persistent ACL to viewer but does not extend the producer-side ACL, thus the viewer's grant alone is insufficient for cross-transaction decryption. The producer must also call FHE.allowThis(handle) before the consumer-side grant.

At every site listed below, a freshly computed FHE-op handle is published to its intended viewer — via FHE.allow(handle, viewer) for the getter and disclosure paths, or via emit to the audience named in the event for the event paths — without a preceding FHE.allowThis(handle) on the same handle, so the recorded handle cannot be user-decrypted by the intended viewer in any subsequent transaction.

Site Fresh handle published
getVestedAmount _resolveEncryptedAmount(VestedAmount, …)FHE.mul / FHE.div post-cliff
getClaimableAmount _resolveEncryptedAmount(ClaimableAmount, …)FHE.select over FHE.sub post-timelock
adminGetVestedAmount _resolveEncryptedAmount(VestedAmount, …)FHE.mul / FHE.div post-cliff
adminGetClaimableAmount _resolveEncryptedAmount(ClaimableAmount, …)FHE.select over FHE.sub post-timelock
_batchDiscloseToParty per-element _resolveEncryptedAmount result for the VestedAmount / ClaimableAmount disclosure types; backs discloseToParty, batchDiscloseToParty, adminDiscloseToParty, and adminBatchDiscloseToParty
TokensClaimed emit transferAmountFHE.asEuint64 cast, plus FHE.mul / FHE.div fee subtraction on the distribution-token branch
AdminWithdrawal emit withdrawAmount = FHE.min(requested, available)

The on-chain effect is benign (no funds are misplaced and no privileged action is enabled), but the user-facing observability primitives the system advertises do not work as intended. Off-chain integrations that rely on event-driven decryption to surface claim amounts to recipients, or on getter calls to surface vested / claimable amounts on demand, observe consistent decryption failures.

Update: Resolved in pull request #43 at commit 2af5fa8 and at commit e01640b.

Conclusion

The audited codebases implement confidential disperse and vesting token systems that carefully navigate the constraints of fully homomorphic encryption on the FHEVM. Both contracts demonstrate well-structured architectural choices, including encrypted select guards, split contract designs to stay within EVM size limits, and clear separation of privileged roles.

A medium-severity issue noted in ConfidentialVesting concerned the vesting revocation path which does not account for rounding discrepancies introduced by position splits, risking reserve over-release or corruption through unguarded encrypted underflow. Several fixes were suggested to harden the codebase against edge-case issues. The findings center on accounting mismatches or rounding errors.

The Token Ops team is appreciated for their responsiveness throughout the engagement and for providing extensive documentation that greatly assisted the audit team in understanding the system. The codebases also contained robust tests and audit readiness documentation which were of great assistance during the audit.

Appendix

Issue Classification

OpenZeppelin classifies smart contract vulnerabilities on a 5-level scale:

  • Critical
  • High
  • Medium
  • Low
  • Note/Information

Critical Severity

This classification is applied when the issue’s impact is catastrophic, threatening extensive damage to the client's reputation and/or causing severe financial loss to the client or users. The likelihood of exploitation can be high, warranting a swift response. Critical issues typically involve significant risks such as the permanent loss or locking of a large volume of users' sensitive assets or the failure of core system functionalities without viable mitigations. These issues demand immediate attention due to their potential to compromise system integrity or user trust significantly.

High Severity

These issues are characterized by the potential to substantially impact the client’s reputation and/or result in considerable financial losses. The likelihood of exploitation is significant, warranting a swift response. Such issues might include temporary loss or locking of a significant number of users' sensitive assets or disruptions to critical system functionalities, albeit with potential, yet limited, mitigations available. The emphasis is on the significant but not always catastrophic effects on system operation or asset security, necessitating prompt and effective remediation.

Medium Severity

Issues classified as being of medium severity can lead to a noticeable negative impact on the client's reputation and/or moderate financial losses. Such issues, if left unattended, have a moderate likelihood of being exploited or may cause unwanted side effects in the system. These issues are typically confined to a smaller subset of users' sensitive assets or might involve deviations from the specified system design that, while not directly financial in nature, compromise system integrity or user experience. The focus here is on issues that pose a real but contained risk, warranting timely attention to prevent escalation.

Low Severity

Low-severity issues are those that have a low impact on the client's operations and/or reputation. These issues may represent minor risks or inefficiencies to the client's specific business model. They are identified as areas for improvement that, while not urgent, could enhance the security and quality of the codebase if addressed.

Notes & Additional Information Severity

This category is reserved for issues that, despite having a minimal impact, are still important to resolve. Addressing these issues contributes to the overall security posture and code quality improvement but does not require immediate action. It reflects a commitment to maintaining high standards and continuous improvement, even in areas that do not pose immediate risks.