1 Executive Summary
This report presents the results of our engagement with Hedgey to review Token Lockup and Vesting Plans.
The review was conducted over four weeks, from June 26, 2023 to July 21, 2023, by Chingiz Mardanov and David Braun. A total of 29 person-days were spent.
2 Scope
Our review focused on the commit hash 6a5ff58c2e83015b83c8de15f1cc61e9ac58f2c7
. The list of files in scope can be found in the Appendix.
2.1 Update: August 3rd - Mitigations
The report was updated to reflect mitigations implemented in commit hash f4299cdba5e863c9ca2d69a3a7dd554ac34af292
, 3544f6ad5a586bcbbd31f1d136a054bd548d0c3a
, 02512711aff5ff6dfcdecba2b89c82b54e0a6db8
, 135297b9a7017d1eb9a7ee473c6fe27ce166cbe7
and 8c7679c7526c29980c6255127c0cd0ba85d000fa
. An additional 10 person days (in the week of July 31 - August 4) were spent, focusing on reviewing changes that were implemented to address our specific findings. We have also included the new file PlanDelegator.sol
into the scope of the review. As with every project that undergoes significant changes it is recommended to conduct a complete re-assessment of the changed system.
2.2 Objectives
Together with the Hedgey team, we identified the following priorities for our review:
- Correctness of the implementation, consistent with the intended functionality and without unintended edge cases.
- Identify known vulnerabilities particular to smart contract systems, as outlined in our Smart Contract Best Practices, and the Smart Contract Weakness Classification Registry.
- Reentrancy Attacks
- Errors with segmenting and combining lockup plans that could cause a user to lose out on funds, or receive additional funds they were not meant to get
- Errors with segmenting and combining lockup plans that allow someone’s to unlock their tokens earlier than scheduled initially
- Errors with On-chain voting vaults that would allow someone to pull their tokens from the contract without authorization
- Calculation errors from the time lock library
2.3 Update: August 3rd - Mitigations
The report was updated to reflect mitigations implemented for the findings. An additional 10 person days (in the week of July 31 - August 4) were spent to conduct the review, focusing on reviewing the changes that were implemented addressing the specific findings. We have also included the PlanDelegator.sol
contract into the scope of the review.
3 Security Specification
This section describes, from a security perspective, the expected behavior of the system under audit. It is not a substitute for documentation. The purpose of this section is to identify specific security properties that were validated by the audit team.
3.1 Actors
The relevant actors are listed below with their respective abilities:
- Vesting or a Lockup Plan Holder - also the recipient of the vested tokens. Wallet or contract that is the owner of a specific ERC-721 token.
- Vesting Admin - address that is in certain cases capable of moving the ERC-721 plan on behalf of the holder as well as revoke the plan.
- Vesting Plan - a linear token unlock plan that is revokable by the vesting admin. Can also be only transferred by vesting admin when that was enabled on creation.
- Voting Vesting Plan - same as vesting plan but with additional Voting Vaults to support on-chain governance.
- Lockup Plan - linear token unlock plan, that can be transferred, segmented and combined again.
- Voting Lockup Plan - same as lockup plan but with additional of Voting Vaults to support on-chain governance.
- Soul Bound Lockup Plan - same as lockup plan but without an ability to transfer it.
3.2 Trust Model
We are delighted to highlight the inherent decentralized nature of the Hedgey platform. Upon conducting a comprehensive review of the contracts, we find it commendable that no upgradeability functionality is incorporated, a decision which aligns well with our principles at Diligence.
However, it is crucial to address one centralization risk that warrants attention. This concern pertains to the vesting plans that allow the adminTransferOBO
flag to be enabled. In such instances, the vesting plan’s administrator gains the ability to transfer tokens on behalf of the recipient, potentially leading to the loss of vested but unclaimed tokens. While we understand that this measure is intended to safeguard less experienced users from the risk of losing access to their vesting wallets, it also introduces the possibility of malicious activities.
4 Findings
Each issue has an assigned severity:
- Minor issues are subjective in nature. They are typically suggestions around best practices or readability. Code maintainers should use their own judgment as to whether to address such issues.
- Medium issues are objective in nature but are not security vulnerabilities. These should be addressed unless there is a clear reason not to.
- Major issues are security vulnerabilities that may not be directly exploitable or may require certain conditions in order to be exploited. All major issues should be addressed.
- Critical issues are directly exploitable security vulnerabilities that need to be fixed.
4.1 Lockup Plans Are Not Well Suited for Trading on Traditional OTC Platforms Medium
Description
For most of the OTC trading platforms with RFQ style the maker or the taker creates an order that is valid for some time and is expecting a specific token ID. In case of a lockup period a trade participants can request to buy a specific plan ID and then give a fixed amount of time to fill that order, assuming that anything past that time that is unvested is guaranteed to go to them. In reality, the taker of such an order can batch two transactions in one block:
- Segment the
planId
the order is expecting into two: one with just 1 wei to vest and the other with the rest. The large plan will have an incremented plan ID. The small plan will have the old ID. - Fill the order and get the full payment for what is now a worthless plan token.
People should be aware of such a possibility before attempting to purchase any lockup plans over OTC platforms.
Recommendation
One way to solve this is to assign both plans a new ID during the segmentation process.
4.2 Architectural Pattern of Internal and External Functions Increases Attack Surface Minor ✓ Fixed
Resolution
f4299cdba5e863c9ca2d69a3a7dd554ac34af292
.
Description
There is an architectural pattern throughout the code of functions being defined in two places: an external wrapper (name
) that verifies authorization and validates parameters, and an internal function (_name
) that contains the implementation logic. This pattern separates concerns and avoids redundancy in the case that more than one external function reuses the same internal logic.
For example, VotingTokenLockupPlans.setupVoting
calls an internal function _setupVoting
and sets the holder
parameter to msg.sender
.
contracts/LockupPlans/VotingTokenLockupPlans.sol:L164-L165
function setupVoting(uint256 planId) external nonReentrant returns (address votingVault) {
votingVault = _setupVoting(msg.sender, planId);
contracts/LockupPlans/VotingTokenLockupPlans.sol:L436-L437
function _setupVoting(address holder, uint256 planId) internal returns (address) {
require(ownerOf(planId) == holder, '!owner');
In this case, however, there is no case in which holder
should not be set to msg.sender
. Because the internal function doesn’t enforce this, it’s theoretically possible that if another internal (or derived) function were compromised then it could call _setupVoting
with holder
set to ownerOf(planId)
, even if msg.sender
isn’t the owner. This increases the attack surface through providing unneeded flexibility.
Other Examples
contracts/LockupPlans/TokenLockupPlans.sol:L107-L113
function segmentPlan(
uint256 planId,
uint256[] memory segmentAmounts
) external nonReentrant returns (uint256[] memory newPlanIds) {
newPlanIds = new uint256[](segmentAmounts.length);
for (uint256 i; i < segmentAmounts.length; i++) {
uint256 newPlanId = _segmentPlan(msg.sender, planId, segmentAmounts[i]);
contracts/LockupPlans/TokenLockupPlans.sol:L244-L245
function _segmentPlan(address holder, uint256 planId, uint256 segmentAmount) internal returns (uint256 newPlanId) {
require(ownerOf(planId) == holder, '!owner');
contracts/VestingPlans/TokenVestingPlans.sol:L115-L117
function revokePlans(uint256[] memory planIds) external nonReentrant {
for (uint256 i; i < planIds.length; i++) {
_revokePlan(msg.sender, planIds[i]);
contracts/VestingPlans/TokenVestingPlans.sol:L226-L228
function _revokePlan(address vestingAdmin, uint256 planId) internal {
Plan memory plan = plans[planId];
require(vestingAdmin == plan.vestingAdmin, '!vestingAdmin');
Recommendation
To reduce the attack surface, consider hard coding parameters such as holder
to msg.sender
in internal functions when extra flexibility isn’t needed.
4.3 Vesting Admin Could Prevent the Recipient From Redeeming Minor ✓ Fixed
Resolution
f4299cdba5e863c9ca2d69a3a7dd554ac34af292
by adding new function toggleAdminTransferOBO
to contracts TokenVestingPlans
and VotingTokenVestingPlans
.
Description
In the vesting part of the protocol, each plan has a vesting admin who can transfer tokens on behalf of the plan holder. However, this setup poses a risk of centralization. For instance, a plan holder might leave their tokens vested for a long time without claiming them. Then, if the vesting admin decides to transfer the plan to a different wallet, the recipient may never be able to claim those tokens.
We understand that this feature is meant to assist novice users who might lose their private keys and need a safety net. Nevertheless, we suggest giving the plan recipient the option to toggle the adminTransferOBO
on and off. This way, they can protect themselves better against any potentially malicious actions from the vesting admin, all without triggering a taxable event.
4.4 Revoking Vesting Will Trigger a Taxable Event Minor ✓ Fixed
Resolution
f4299cdba5e863c9ca2d69a3a7dd554ac34af292
.
Description
From the previous conversations with the Hedgey team, we identified that users should be in control of when taxable events happen. For that reason, one could redeem a plan in the past. Unfortunately, the recipient of the vesting plan can not always be in control of the redemption process. If for one reason or another the administrator of the vesting plan decides to revoke it, any vested funds will be sent to the vesting plan holder, triggering the taxable event and burning the NFT.
Examples
contracts/VestingPlans/TokenVestingPlans.sol:L226-L237
function _revokePlan(address vestingAdmin, uint256 planId) internal {
Plan memory plan = plans[planId];
require(vestingAdmin == plan.vestingAdmin, '!vestingAdmin');
(uint256 balance, uint256 remainder, ) = planBalanceOf(planId, block.timestamp, block.timestamp);
require(remainder > 0, '!Remainder');
address holder = ownerOf(planId);
delete plans[planId];
_burn(planId);
TransferHelper.withdrawTokens(plan.token, vestingAdmin, remainder);
TransferHelper.withdrawTokens(plan.token, holder, balance);
emit PlanRevoked(planId, balance, remainder);
}
contracts/VestingPlans/VotingTokenVestingPlans.sol:L245-L263
function _revokePlan(address vestingAdmin, uint256 planId) internal {
Plan memory plan = plans[planId];
require(vestingAdmin == plan.vestingAdmin, '!vestingAdmin');
(uint256 balance, uint256 remainder, ) = planBalanceOf(planId, block.timestamp, block.timestamp);
require(remainder > 0, '!Remainder');
address holder = ownerOf(planId);
delete plans[planId];
_burn(planId);
address vault = votingVaults[planId];
if (vault == address(0)) {
TransferHelper.withdrawTokens(plan.token, vestingAdmin, remainder);
TransferHelper.withdrawTokens(plan.token, holder, balance);
} else {
delete votingVaults[planId];
VotingVault(vault).withdrawTokens(vestingAdmin, remainder);
VotingVault(vault).withdrawTokens(holder, balance);
}
emit PlanRevoked(planId, balance, remainder);
}
Recommendation
One potential workaround is to only withdraw the unvested portion to the vesting admin while keeping the vested part in the contract. That being said amount
and rate
variables would need to be updated in order not to allow any additional vesting for the given plan. This way plan holders will not be entitled to more funds but will be able to redeem them at the time they choose.
4.5 Use of selfdestruct
Deprecated in VotingVault
Minor ✓ Fixed
Resolution
f4299cdba5e863c9ca2d69a3a7dd554ac34af292
.
Description
The VotingVault.withdrawTokens
function invokes the selfdestruct
operation when the vault is empty so that it can’t be used again.
The use ofselfdestruct
has been deprecated and a breaking change in its future behavior is expected.
Examples
contracts/sharedContracts/VotingVault.sol:L36-L39
function withdrawTokens(address to, uint256 amount) external onlyController {
TransferHelper.withdrawTokens(token, to, amount);
if (IERC20(token).balanceOf(address(this)) == 0) selfdestruct;
}
Recommendation
Remove the line that invokes selfdestruct
and consider changing internal state so that future calls to delegateTokens
always revert.
4.6 Balance of msg.sender
Is Used Instead of the from
Address Minor ✓ Fixed
Resolution
f4299cdba5e863c9ca2d69a3a7dd554ac34af292
.
Description
The TransferHelper
library has methods that allow transferring tokens directly or on behalf of a different wallet that previously approved the transfer. Those functions also check the sender balance before conducting the transfer. In the second case, where the transfer happens on behalf of someone the code is checking not the actual token spender balance, but the msg.sender
balance instead.
Examples
contracts/libraries/TransferHelper.sol:L18-L25
function transferTokens(
address token,
address from,
address to,
uint256 amount
) internal {
uint256 priorBalance = IERC20(token).balanceOf(address(to));
require(IERC20(token).balanceOf(msg.sender) >= amount, 'THL01');
Recommendation
Use the from
parameter instead of msg.sender
.
4.7 Refactor Large Functions for Readability
Description
Function bodies larger than a typical screenful of text are harder to read and to reason about security properties.
Examples
VotingTokenLockupPlans._combinePlans
is 98 lines long.VotingTokenLockupPlans._segmentPlan
is 72 lines long.TokenLockupPlans._segmentPlan
is 66 lines long.
Recommendation
Refactor large functions into compositions of smaller, easier-to-understand functions.
4.8 Unused Code in Source Files ✓ Fixed
Resolution
f4299cdba5e863c9ca2d69a3a7dd554ac34af292
.
Description
There is unused code in the source files.
Examples
The function TimelockLibrary.totalPeriods
isn’t used and appears to be incorrect (rounds down instead of up).
contracts/libraries/TimelockLibrary.sol:L28-L31
/// @notice function to calculate the total periods in a given plan based on the rate and amount
function totalPeriods(uint256 rate, uint256 amount) internal pure returns (uint256 periods) {
periods = amount / rate;
}
ERC721Delegate.wasTransferred
is written but not read:
contracts/ERC721Delegate/ERC721Delegate.sol:L26-L27
// Mapping if a token has been transferred
mapping(uint256 => bool) public wasTransferred;
Recommendation
Remove unused code to reduce confusion and to decrease the attack surface.
4.9 Use Custom Errors to Save Gas
Description
As of Solidity 0.8.4 it is possible to save gas when reporting error conditions by using custom errors instead of strings.
Examples
contracts/ERC721Delegate/ERC721Delegate.sol:L40
require(index < ERC721.balanceOf(owner), 'ERC721Enumerable: owner index out of bounds');
contracts/ERC721Delegate/ERC721Delegate.sol:L59
require(index < totalSupply(), 'ERC721Enumerable: global index out of bounds');
Recommendation
We recommend using custom errors to save gas.
4.10 Use _beforeTokenTransfer
to Override Behavior in OpenZeppelin Token Contracts Partially Addressed
Resolution
As of commit f4299cdba5e863c9ca2d69a3a7dd554ac34af292
, TokenLockupPlans_Bound
and VotingTokenLockupPlans_Bound
now use _beforeTokenTransfer
but TokenVestingPlans
and VotingTokenVestingPlans
still do not.
Client response:
Did not implement for the Vesting plans because the impact would override and complicate functionality desired through the vestingAdminTransferOBO, because of the way the hooks process before and after it would require significant and possibly risky changes
Description
Contracts such as TokenVestingPlans
, VotingTokenVestingPlans
, TokenLockupPlans_Bound
, and VotingTokenLockupPlans_Bound
add special conditions for allowing the transfer of tokens by overriding the transferFrom
, _safeTransfer
, and _transfer
functions in OpenZeppelin token contracts. While workable this approach can be error-prone and may break during future upgrades to the underlying contracts.
For example, in the unreleased version of OpenZeppelin’s contracts, the ERC20._transfer
function is no longer virtual and contains the warning:
NOTE: This function is not virtual, {_update} should be overridden instead.
Examples
contracts/VestingPlans/TokenVestingPlans.sol:L282
function transferFrom(address from, address to, uint256 tokenId) public override(IERC721, ERC721) {
contracts/VestingPlans/TokenVestingPlans.sol:L291
function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal override {
contracts/LockupPlans/NonTransferable/TokenLockupPlans_Bound.sol:L21
function _transfer(address from, address to, uint256 tokenId) internal virtual override {
Recommendation
OpenZeppelin recognizes this as a common use case and provides a hook for cleaner control over transfer behavior. Use the _beforeTokenTransfer hook with version 4 contracts to enforce transfer conditions.
Please note however that the _beforeTokenTransfer
hook will be deprecated in the next release of OpenZeppelin’s contracts in favor of a new function called _update
.
4.11 Use calldata
Instead of memory
for External Function Arguments Data Location Partially Addressed
Resolution
TokenLockupPlans.segmentPlan
, TokenLockupPlans.segmentAndDelegatePlans
, VotingTokenLockupPlans.segmentPlan
, and VotingTokenLockupPlans.segmentAndDelegatePlans
.
Description
Reference types (e.g., arrays, mappings, and structs) in function arguments must declare the “data location” for where they are stored. There are two options for external functions: calldata
or memory
. calldata
arguments are immutable which reduces complexity and improves code readability. memory
arguments are mutable and add an implicit copy operation.
Examples
contracts/LockupPlans/TokenLockupPlans.sol:L72
function redeemPlans(uint256[] memory planIds) external nonReentrant {
contracts/VestingPlans/VotingTokenVestingPlans.sol:L123
function revokePlans(uint256[] memory planIds) external nonReentrant {
contracts/LockupPlans/TokenLockupPlans.sol:L107-L110
function segmentPlan(
uint256 planId,
uint256[] memory segmentAmounts
) external nonReentrant returns (uint256[] memory newPlanIds) {
Recommendation
The Solidity documentation makes the following recommendation:
If you can, try to use
calldata
as data location because it will avoid copies and also makes sure that the data cannot be modified.
We recommend always using calldata
for external function parameters unless doing so would incur a serious performance penalty or make code harder to read.
Appendix 1 - Files in Scope
This audit covered the following files:
File | SHA-1 hash |
---|---|
contracts/ERC721Delegate/ERC721Delegate.sol | 4c6d778a225ff249d285341be3a128a24430260a |
contracts/LockupPlans/NonTransferable/TokenLockupPlans_Bound.sol | 529f8422ca5c1b8312df594a2ebc93063b08e0bc |
contracts/LockupPlans/NonTransferable/VotingTokenLockupPlans_Bound.sol | fce83a3ec6e677ca92a445411f1d2c0b2a88540c |
contracts/LockupPlans/TokenLockupPlans.sol | 3ab057c1df70042c6b4ee65c261cbffa3784c295 |
contracts/LockupPlans/VotingTokenLockupPlans.sol | 98d06ee151c87e456d2dd63c1bac766369cf1487 |
contracts/Periphery/BatchPlanner.sol | c0d3c73b59371afc5ee1312156105b2ff778385f |
contracts/Periphery/ClaimCampaigns.sol | 43d0d3d734e398c2a4f184bc362548d4bbbb4920 |
contracts/VestingPlans/TokenVestingPlans.sol | ca8c0e8934d2aff4130edf599b347a35ace5431e |
contracts/VestingPlans/VotingTokenVestingPlans.sol | 1f8fe33624358e9787cef581bdb46c797f19c333 |
contracts/interfaces/IDelegateNFT.sol | 26b381ac7b2a987f261682c4ebb989efbee06f97 |
contracts/interfaces/ILockupPlans.sol | 4393120bef23f18e900ebe3f22a475bfa2d59ff9 |
contracts/interfaces/IVestingPlans.sol | 74862d6fe439c6f85582a6726b787a80341e1d2e |
contracts/libraries/TimelockLibrary.sol | b9d052a25ebaa233056bd9fbd4523781cbede99c |
contracts/libraries/TransferHelper.sol | a64d729331d35d311a1b62da06f3c970cb508194 |
contracts/sharedContracts/LockupStorage.sol | 51360e24db40eaa733012d54e3bfa0a5023455e9 |
contracts/sharedContracts/PlanDelegator.sol | 4bbfad31e1577d134b03eef0f60ee36696643458 |
contracts/sharedContracts/URIAdmin.sol | a358e8ff9f73137a9cc1d86219b7aa5dff9bee00 |
contracts/sharedContracts/VestingStorage.sol | 520dd9cb3a534bf81790ff4d3ad6bfdf8c95ab9f |
contracts/sharedContracts/VotingVault.sol | 3c424708fee57ef12e55e2e6ccdd8ab4bc8636a8 |
Appendix 2 - Disclosure
ConsenSys Diligence (“CD”) typically receives compensation from one or more clients (the “Clients”) for performing the analysis contained in these reports (the “Reports”). The Reports may be distributed through other means, including via ConsenSys publications and other distributions.
The Reports are not an endorsement or indictment of any particular project or team, and the Reports do not guarantee the security of any particular project. This Report does not consider, and should not be interpreted as considering or having any bearing on, the potential economics of a token, token sale or any other product, service or other asset. Cryptographic tokens are emergent technologies and carry with them high levels of technical risk and uncertainty. No Report provides any warranty or representation to any Third-Party in any respect, including regarding the bugfree nature of code, the business model or proprietors of any such business model, and the legal compliance of any such business. No third party should rely on the Reports in any way, including for the purpose of making any decisions to buy or sell any token, product, service or other asset. Specifically, for the avoidance of doubt, this Report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. CD owes no duty to any Third-Party by virtue of publishing these Reports.
PURPOSE OF REPORTS The Reports and the analysis described therein are created solely for Clients and published with their consent. The scope of our review is limited to a review of code and only the code we note as being within the scope of our review within this report. Any Solidity code itself presents unique and unquantifiable risks as the Solidity language itself remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond specified code that could present security risks. Cryptographic tokens are emergent technologies and carry with them high levels of technical risk and uncertainty. In some instances, we may perform penetration testing or infrastructure assessments depending on the scope of the particular engagement.
CD makes the Reports available to parties other than the Clients (i.e., “third parties”) – on its website. CD hopes that by making these analyses publicly available, it can help the blockchain ecosystem develop technical best practices in this rapidly evolving area of innovation.
LINKS TO OTHER WEB SITES FROM THIS WEB SITE You may, through hypertext or other computer links, gain access to web sites operated by persons other than ConsenSys and CD. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such web sites’ owners. You agree that ConsenSys and CD are not responsible for the content or operation of such Web sites, and that ConsenSys and CD shall have no liability to you or any other person or entity for the use of third party Web sites. Except as described below, a hyperlink from this web Site to another web site does not imply or mean that ConsenSys and CD endorses the content on that Web site or the operator or operations of that site. You are solely responsible for determining the extent to which you may use any content at any other web sites to which you link from the Reports. ConsenSys and CD assumes no responsibility for the use of third party software on the Web Site and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.
TIMELINESS OF CONTENT The content contained in the Reports is current as of the date appearing on the Report and is subject to change without notice. Unless indicated otherwise, by ConsenSys and CD.