Guardian's review of Voting Escrow Updates for Magna, published May 2024. The report records 26 findings across 2 review rounds, including 7 high and 5 medium.
- Published
- Review window
- April 8 to May 1, 2024
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum, Base, Optimism, Polygon, Arbitrum, BNB Chain
- Sector
- Infrastructure
- 0 Critical
- 7 High
- 5 Medium
- 14 Low
- 0 Informational
Scope
4 files in scope · 333 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/MerkleVester.sol | 193 | 313 |
src/MerkleValidator.sol | 10 | 14 |
src/EscrowVester.sol | 94 | 134 |
src/DelegationEscrow.sol | 36 | 61 |
Findings 26
Main Review
18 findings · April 8 to 11, 2024-
H-01 High Withdraw Not Possible Without Prior Delegation Logical Error Resolved
Description
The
schedulesmapping inside MerkleVester is lazily stored as acknowledged by code comments. Therefore the withdrawal address within schedules is initiallyaddress(0)and is updated via the_checkOrSetOriginalBeneficiary()function. This function is used in three different places mainly:- During delegation related calls:
- Delegation related calls (
delegateFunds(),reclaimDelegatedFunds(),withdrawEarned()) calls_validateEscrowRequest()which in turn this function sets the withdrawal address in schedule before any operation correctly.
- Delegation related calls (
- Changing beneficiary call:
transferBeneficiaryAddress()function also correctly sets the withdrawal address in schedule before changing it.
- Withdrawal request:
withdraw()call continue with internal withdrawal calls according to the type of the vest. In both internal functions however (_withdrawCalendar(), and_withdrawInterval()) setting withdraw address comes after withdraw address check:if ((schedules[calendar.allocation.id].withdrawalAddress != msg.sender) && !adminWithdrawFeature) revert UnauthorizedWithdrawal();
Hence when
adminWithdrawFeatureis false, users cannot directly do withdrawals. In order to withdraw their funds in this scenario, the user has to either first delegate to escrow and then reclaim it so that their withdraw address will be recorded. However, if the escrow feature is not enabled, then the user cannot even delegate funds.An admin can potentially transfer the beneficiary, but this is only possible if
transferableByAdminis enabled and unfavorable when there are numerous allocations. Ultimately, user withdrawals from the vester are prevented, a core functionality of the protocol.Recommendation
Inside
_withdrawCalendarand_withdrawInterval, call function_checkOrSetOriginalBeneficiaryprior toif ((withdrawalAddress != msg.sender) && !adminWithdrawFeature) revert UnauthorizedWithdrawal(); - During delegation related calls:
-
H-02 High Revoked Allocations Are Stuck If Revoke All Feature Is Disabled Logical Error Resolved
Description
The benefactor can
revokean allocation, such that funds are reclaimed and send to the vester contract, and withdrawals for the allocation by the beneficiary are prevented. In the case that therevokeAllFeatureis disabled, the benefactor will be unable to retrieve these funds, since both functionsdefundandrevokeAllrely on the feature being enabled.Function
rescueTokenswill revert since the token being vested cannot be rescued:if (_errantTokenAddress == token) revert InvalidToken();Ultimately, revoked funds are stuck in the MerkleVester contract.Recommendation
Consider removing the
revokeAllFeaturefrom thedefundfunction entirely, or at minimum making a separate feature type fordefundand documenting this behavior. -
H-03 High Delegations Are Still Possible After Revoke Or Cancel Logical Error Resolved
Description
When benefactor revoke's someone vesting, their
terminatedTimestampis changed so that they can not withdraw any funds (their withdrawable will be 0) as can be seen:withdrawableschedules[allocationId].terminatedTimestamp = uint32(1);The problem is that revoked users can still delegate the revoked funds. When the
revokeAllFeatureis enabled, the benefactor has the capability to remove specified amount of funds from vesting. The normal workflow for revocations can be assumed as: first revoke and then defund revoked amount.When this happens, revoked users will have nothing as withdrawable and then the funds allocated to them initially will be removed from contract. However, since these users can still delegate funds that is recorded inside
Allocationstruct, they essentially will be able to delegate funds that do not belong to them which results in blocking other user's ability to delegate and withdraw because of insufficient funds in the contract.Furthermore, allowing delegation after
revokeorcancelcan lead to funds stuck in the escrow contract forever. The allocation cannot be revoked or cancelled again to reclaim those funds as theterminatedTimestampis non-zero. IfrevokeAllFeatureis false (not ideal if true as all allocations will be revoked) andtransferableByAdminis false then benefactor can't reclaim those funds and they are stuck in the escrow contract.Recommendation
Validate that the
terminatedTimestampfor the allocation is zero inside functiondelegateFunds. -
M-01 Medium Benefactor May Steal Delegation Reward In Other Tokens Gaming Resolved
Description
With the
rescueTokenFromEscrowfunction the benefactor may withdraw delegation rewards from any delegator contract where tokens other than the delegation token have been rewarded.However these rewards ought to belong to the beneficiary who delegated to receive those rewards, the benefactor should not be able to take them.
Recommendation
Consider allowing potential reward tokens to be blacklisted from the
rescueTokenFromEscrowfunction. Otherwise modify therescueTokenFromEscrowfunction such that only beneficiaries are able to rescue funds from their delegation contract. -
M-02 Medium Lack Of Support For Certain Rebasing Tokens Logical Error Resolved
Description
Certain rebasing tokens can not only increase in balance, but also decrease. For such tokens such as Ampleforth, the following validation in the EscrowVester could revert after rebases:
if (_escrowedFunds > IERC20(escrowToken).balanceOf(address(delegateAddress))) revert UnauthorizedDelegation();This can lead to a situation where a user delegates but after a rebase is unable to
_reclaimFunds.Recommendation
Clearly document to users that rebasing tokens are not fully supported.
-
M-03 Medium Anyone Can Reduce Beneficiary’s Voting Power Logical Error Acknowledged
Description
Inside the internal withdraw functions, the following validation is performed:
if ((schedules[calendar.allocation.id].withdrawalAddress != msg.sender) && !adminWithdrawFeature) revert UnauthorizedWithdrawal();This check does the following:
- If
adminWithdrawFeatureis false, only letmsg.sendercallwithdraw() - If
adminWithdrawFeatureis true, don't revert.
While the purpose of the validation was to give permission only to the benefactor (admin) in the case that
adminWithdrawFeatureis enabled, it actually gives permission to everyone.Since withdraw calls reclaims all funds from delegation escrow, the delegatee will lose all their voting power that comes from escrow. This prevents voting power from being delegated and maintained, which is a core functionality of the escrow updates. The withdrawals can be 1 wei and done continuously, as all funds are reclaimed from the escrow contract regardless of the requested withdrawal amount.
Recommendation
If
msg.senderis not beneficiary but admin feature is enabled, perform a benefactor check. - If
-
L-01 Low Redundant Fully Unlocked Validation Optimization Resolved
Description
In the
cancelfunction thegetLeafJustAllocationDataWithFullyUnlockedCheckfunction is used to perform validation on the fully unlocked timestamp.However this function is invoked twice, when the validation from the first invocation is sufficient.
Recommendation
Remove the second invocation of the
getLeafJustAllocationDataWithFullyUnlockedCheckfunction. -
L-02 Low totalEscrowed Storage Variable Re-assigned In A Loop Optimization Resolved
Description
In the
_revokeDelegatedfunction thetotalEscrowedstorage variable is decremented upon each iteration of the for-loop.However it would be cheaper to cache the initial
totalEscrowedvalue before beginning the loop and decrementing the cached value in the loop before finally re-assigning thetotalEscrowedstorage variable to the updated cached value.Recommendation
Cache the initial
totalEscrowedvalue before the for-loop, decrement the cached value on the stack in the for-loop, and assign the updated cached value to the storage variable after the for-loop. -
L-03 Low Withdraw Function Unnecessarily Undelegates For A user Unexpected Behavior Resolved
Description
When withdrawing,
MerkleVester.withdrawfunction callsEscrowVester._reclaimFundswith 0 hardcoded for the amount out.This will lead to all funds being undelegated from
DelegationEscrowcontract. If the amount being withdrawn is less than the total available, it is unnecessary to withdraw all of the tokens.In some instances it is unnecessary to even call
EscrowVester._reclaimFundsat all. This will force the user to callMerkleVester.delegateFundsagain.Recommendation
Check the vested amount prior to calling
EscrowVester._reclaimFunds. Then verify if the user is required to pull out the entirety of their funds fromDelegationEscrowcontract. -
L-04 Low Modular Blacklist Suggestion Suggestion Acknowledged
Description
In the EscrowVester contract the function selector blacklist does not allow benefactors to add additional selectors that are permanently blacklisted other than the ones hardcoded in the constructor.
Therefore if a benefactor wishes to use the MerkleVester escrow feature with a token that has a sensitive function that is not already included in the hardcoded blacklisted selectors, then the benefactor must assume some level of trust from the users that they will not whitelist the sensitive selector.
Recommendation
Consider implementing functionality such that the benefactor may add additional blacklisted selectors upon construction.
-
L-05 Low Memory Variable Declared In For-loop Optimization Resolved
Description
Declaring a memory variable inside a for loop is very gas intensive in Solidity. On each iteration a new memory variable is declared. The first 22 words of memory are priced linearly, but after that the price increases quadratically. This will significantly increase the chance of running out of gas in a transaction if the for loop iterates over a large array.
Recommendation
Declare the memory variable outside of the for loop and then reassign it in each iteration.
-
L-06 Low totalEscrowed and escrowToken Are Private Variables Suggestion Resolved
Description
totalEscrowandescrowTokenare private variables which will not have a getter function.totalEscrowis kept track of in multiple locations costing gas from an sload and sstore. It currently tacks on unnecessary gas costs to transactions with no use case.And
escrowTokenmay be a useful queryable value.Recommendation
Declare the totalEscrowed and escrowToken as public variables.
-
L-07 Low Lacking Events Events Resolved
Description
Events allow users to efficiently perform analysis of on-chain activity. Apps like Dune Analytics heavily rely on reading event logs. Adding events will allow users to gain insights on how the protocol is being utilized.
Recommendation
Add events for major state changing functions.
-
L-08 Low adminWithdrawFeature Not Declared Immutable Optimization Resolved
Description
The
adminWithdrawFeatureis only assigned to in the constructor and cannot be updated, yet it is not declared as immutable.Recommendation
Declare the
adminWithdrawFeatureas immutable. -
L-09 Low Multisig Warning Warning Resolved
Description
The
benefactorrole carries a lot of admin privileges that can lead to loss of users funds or loss of functionality. For example, thebenefactorcan prevent further delegations even if theescrowFeatureis enabled, via callingwhitelistSelectorwithdelegate(address)set to false. Thebenefactorshould be a multisig wallet to increases chances that the account is not compromised.Recommendation
Be sure to recommend that customers use a multisig wallet for the
benefactorrole. Furthermore, clearly document admin priveleges. -
L-10 Low Risk Of Selector Collision Warning Resolved
Description
The EscrowVester whitelists certain functions to be called using their function selectors. However, multiple functions can produce the same selector, leading to a hash collision.
With the current whitelist, a user may pass arbitrary data to a token that doesn't call
delegatebut another function which happens to have the same selector, and lead to unexpected behavior.Recommendation
Clearly document this risk to users.
-
L-11 Low Yield Can Be Withdrawn After Revoke Warning Resolved
Description
Even after a benefactor calls function
revokeAll, the beneficiary can still withdraw earned yield throughwithdrawEarned. This may be unexpected if the the benefactor intended for the revoke to return absolutely all funds.Recommendation
Clearly document this behavior to users.
-
L-12 Low Invalid Withdrawal Invariant Check Logical Error Resolved
Description
In the
_withdrawToBeneficiaryfunction the_validateWithdrawalInvariantsfunction invocation passes thewithdrawableAmountinstead of therequestedWithdrawalAmountto verify the withdrawal invariants. This isn’t entirely accurate as thewithdrawableAmountdoes not necessarily match how much is being withdrawn, however those is no veritable impact.Recommendation
Perform the
_validateWithdrawalInvariantscheck on therequestedWithdrawalAmountrather than the entirewithdrawableAmount. Otherwise simplify the invariant check to only do a post check that involves the contract balance afterwards.
Remediation Review
8 findings · April 30 to May 1, 2024-
H-01 High Funds Are Reclaimed Twice In WithdrawInterval Logical Error Acknowledged
Description
In the
_withdrawIntervalfunction the original_reclaimFundscall remains, while an additional_reclaimToFundWithdrawcall has been added.Recommendation
Remove the original
_reclaimFundscall in the_withdrawIntervalfunction. -
H-02 High reclaimToFundWithdraw Incorrectly Implemented Logical Error Acknowledged
Description
The
_reclaimToFundWithdrawfunction is inappropriately implemented such that reclaims will occur when thewithdrawalAmountcan be sufficiently withdrawn from the non-escrowed portion of the user's allocation.Additionally, when the unclaimed amount is greater than the escrowed amount escrowed tokens will not be reclaimed when they ought to be.
The clearest example is an entirely vested allocation, where the escrowed portion will not be reclaimed at all, but the entire token amount will still be claimed from the contract's balance. This user no longer has an allocation to vest and so therefore should not garner any voting power from escrowed tokens.
Additionally, the fact that the existing
_reclaimToFundWithdrawimplementation passes the written tests indicates a lack of coverage for the new behavior.Recommendation
Implement new written test cases which add coverage for the new expected behavior surrounding the reclaiming of funds for withdrawal.
Additionally, use the suggested
_reclaimToFundWithdrawfunction:function _reclaimToFundWithdraw(uint256 withdrawalAmount, Allocation memory allocation, uint256 withdrawn) internal { uint256 reclaim; // How much do we need to withdraw from escrow? uint256 leftToClaim = allocation.totalAllocation - withdrawn; uint256 allocationNotInEscrow = leftToClaim - escrowedFunds[allocation.id]; if (allocationNotInEscrow < withdrawalAmount) { reclaim = withdrawalAmount - allocationNotInEscrow; } uint256 contractBalanceWithReclaim = IERC20(token).balanceOf(address(this)) + reclaim; if (contractBalanceWithReclaim < withdrawalAmount) { reclaim += withdrawalAmount - contractBalanceWithReclaim; } if (reclaim > 0) _reclaimFunds(reclaim, allocation.id); } -
H-03 High Revoked Allocations Are Stuck Logical Error Acknowledged
Description
In the case that an allocation is cancelled partway through the vest, function
defundis at risk of underflowing and preventing the terminated funds from being withdrawn. Consider the following example:- Bob creates 2 allocations for 50 tokens each, thus
totalKnownObligations = 100. - Bob funds the Merkle contract for 100 tokens.
- Alice and Chris delegate 50 tokens each, and merkle vester balance is now 0.
- Bob cancels Alice's allocations halfway through, thus
totalTerminated = 25. contractBalance = 50since 50 tokens are reclaimed from delegation escrow.- Bob calls
defundwhich reverts sinceundefundable = 100 - 25 - 0 = 75butcontractBalance = 50sodefundable = 50 - 75underflows.
Recommendation
Remove the
defundableandundefundablelogic. Afterwards, add awithdrawnFromTerminatedvariable, and then only allow thetotalTerminated - withdrawnFromTerminatedto be withdrawn. - Bob creates 2 allocations for 50 tokens each, thus
-
H-04 High revokeAll Allows Revoked Vests To Claim In The Future Logical Error Acknowledged
Description
When
revokeAll()is invoked, it undelegates all users’ delegations and then transfers all tokens out of the vester smart contract.If the Benefactor decides to create a new unlock schedule and fund the vester smart contract again, users from previous unlock schedules are still eligible to claim their tokens that were supposed to be revoked. This will lead to theft of tokens from users of the current unlock schedule.
Recommendation
Set
schedules[allocationId].terminatedTimestamptouint32(1)inside of_revokeAllDelegated(). This will prevent users from being able to withdraw and delegate revoked tokens, similarly tocancel()andrevoke().Additionally update the
totalTerminatedby the terminated amount in therevokeAllfunction.Alternatively, consider removing the
_revokeAllDelegatedandrevokeAllfunctions and simply require the benefactor to revoke vests one at a time. -
M-01 Medium Users May Not Be Able To Delegate Logical Error Acknowledged
Description
If a contract is not fully funded for the entirety of an unlock schedule, only a portion of users will be able to delegate their funds.
_validateEscrowRequestallows a user to delegate their total allocation minus what they have withdrawn, subtracted by the amount they are currently escrowing. IfMerkleVester.solis not funded for the entirety of an unlock schedule, user’s who delegate first, will be able to delegate an unfair proportion of tokens to users who delegated later.Example:
Total allocation for all users: 1000 Alice’s allocation: 500 Bob’s allocation: 500 Contract balance: 500
Alice decides to delegate all of her allocation. The contract balance would be 0, since all of the funds will be in Alice’s delegation escrow. The transaction will pass the validations because Alice’s allocation is 500 and she has not withdrawn or escrowed yet. Now Bob is unable to delegate since all funds are stored in Alice’s delegation escrow.
Recommendation
Since the users’ total allocations are lazily stored, there is no efficient solution to verify that the contract is properly funded. Warn users to verify the contract is properly funded for the entirety of an unlock schedule. Additionally, we added a check inside our recommended revision for
_reclaimToFundWithdrawto cover withdrawal debt obligations. -
M-02 Medium Requested Withdrawal Amount Is Not Adjusted Logical Error Acknowledged
Description
In the case that
(requestedWithdrawalAmount > withdrawableAmount), the attempted withdraw reverts which is unexpected as right below therequestedWithdrawalAmountis adjusted to be no more than the available withdrawable:requestedWithdrawalAmount = _optionalOrMax(requestedWithdrawalAmount, withdrawableAmount);Consequently, requested withdrawals that should be auto-adjusted and pass execution actually end up reverting.
Recommendation
Remove
if (requestedWithdrawalAmount > withdrawableAmount) revert InsufficientFunds();since_optionalOrMaxis used to ensure this condition. -
L-01 Low Invariants For Withdrawal Functions Validation Acknowledged
Description
When an allocation is revoked or cancelled, all their escrowed funds are reclaimed and it is not possible to delegate those funds further. Recommendation given for
H-02should be combined with invariant checks for internal withdrawal functions so that revoked or cancelled allocations won't try reclaiming from escrow when there is no need to.Recommendation
Change
_withdrawIntervaland_withdrawCalendarfunctions implementation with following diff in their respected places.+ if (schedules[allocationId].terminatedTimestamp == 1) { + revert(); + } uint256 unclaimedAmount = vestedAmount - schedule.withdrawn; uint256 requested = _optionalOrMax(withdrawalAmount, unclaimedAmount); + if (schedules[allocationId].terminatedTimestamp == 0) { + _reclaimToFundWithdraw(requested, allocation, allocationId); + } - _reclaimToFundWithdraw(requested, unclaimedAmount, allocationId); -
L-02 Low Temporary Locking Of Revoked Funds Logical Error Acknowledged
Description
Since
totalKnownObligationsis only updated when a user interacts with the protocol, callingcancel()orrevoke()can causetotalTerminatedto be greater thantotalKnownObligations. This leads to a revert inside ofdefund()when calculatingunDefundable:uint256 unDefundable = totalKnownObligations - totalTerminated - totalWithdrawn;. This will cause funds to be locked inside of the vester contract, until enough new users have interacted with the vester contract and can increasetotalKnownObligationsto be greater thantotalTerminated.Recommendation
Inside
cancel()andrevoke(), check iftotalKnownObligationshas been accounted for for the user. IftotalKnownObligationshas not been set, increasetotalKnownObligationsto prevent a revert indefund().
No findings match.
More from Magna
All 9 reportsPut your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.