Organization
- @puffer
Engagement Type
Cantina Reviews
Period
-
Researchers
Findings
Medium Risk
2 findings
2 fixed
0 acknowledged
Low Risk
4 findings
4 fixed
0 acknowledged
Informational
25 findings
14 fixed
11 acknowledged
Gas Optimizations
3 findings
3 fixed
0 acknowledged
Medium Risk2 findings
startRestakingValidators() doesn't interact with eigenlayer
State
- Fixed
PR #31
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Low Submitted by
Gerard Persoon
Description
Function
startRestakingValidators()
does give the right to withdraw to eigenlayer. This means eigenlayer can't use it to restake (fully understood with help of the project).Also an additional
cli
action is required to register with eigenlayer. This is not clear in the documentation.Recommendation
Investigate what should be done to give the right to withdraw to eigenlayer.
Consider making the required actions clear in the documentation. Also consider adding a comment at function
startRestakingValidators()
making clear additional actions are required.Puffer
Added to the documentation.
getMerkleRoot returns empty merkle root when pendingMerkleRoot is not set (e.g. after cancelPendingMerkleRoot)
Description
When user claims the token reward, the code calls
getMerkleRoot()
to retrieve the merkle root. WhenpendingMerkleRoot
is not set thenblock.timestamp >= pendingMerkleRootActivationTimestamp
is alwaystrue
and then an empty merkle root is returned. This blocks the user from claiming the reward.This situation happens when
cancelPendingMerkleRoot()
is called: bothpendingMerkleRoot
andpendingMerkleRootActivationTimestamp
are reset to0
.After the
pendingMerkleRoot
is set, the user needs to wait for 7 days to makeblock.timestamp >= pendingMerkleRootActivationTimestamp
returntrue
. Repeatedly doingcancelPendingMerkleRoot()
can result in a complete denial of service for claiming (this normally shouldn't happen).Recommendation
The code has to validate if
pendingMerkleRoot
is empty as well when retrievingmerkleRoot
:function getMerkleRoot() public view returns (bytes32) {- if (block.timestamp >= pendingMerkleRootActivationTimestamp) {+ if (pendingMerkleRoot != bytes32(0) && block.timestamp >= pendingMerkleRootActivationTimestamp) { return pendingMerkleRoot; } return merkleRoot;}
Low Risk4 findings
setNewMerkleRoot() with same newMerkleRoot
State
- Fixed
PR #54
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
When
setNewMerkleRoot()
is called with the samenewMerkleRoot
, then thependingMerkleRootActivationTimestamp
is shifted forward, which leads to a delay in activation (assuming it is done before thependingMerkleRootActivationTimestamp
).If accidentally (due to a configuration error), this is done repeatedly, the
newMerkleRoot
will never be activated.Recommendation
Consider checking that
newMerkleRoot!=pendingMerkleRoot
.Not all init functions are called
State
- Fixed
PR #54
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Not all
init
functions are called in theinitialize()
functions. The risks is limited because these functions are currently empty. However that could change in the future.Recommendation
Consider also calling the following:
In
initialize()
ofUniFiAVSManager
:__UUPSUpgradeable_init()
__Context_init()
In
initialize()
ofInstitutionalVault
:-
__Context_init()
Puffer
Fixed in: inst PR 28 and preconf PR 54.
Denial of service in withdrawNonRestakedETH()
State
- Fixed
PR #30
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
When redrawing the last ETH from
NonRestakingWithdrawalCredentials
withwithdrawETH()
, the following calulcation will revert is some additional ETH has been donated to theNonRestakingWithdrawalCredentials
contract.$.nonRestakedValidatorsETH -= uint128(difference);
This could lead to a denial of service of the call to
withdrawNonRestakedETH()
.This can be fixed with
setValidatorsETH()
, but in the mean time additional ETH can be donated.Note: a donation of 1 Wei is sufficient.
Recommendation
Consider not reverting when
$.nonRestakedValidatorsETH < uint128(difference)
.This can be done with
saturatingSub()
.balanceBefore and balanceAfter is vulnerable with reentrancy attacks
State
- Fixed
PR #20
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The construction of using
balanceBefore
andbalanceAfter
is vulnerable with reentrancy attacks. Because in that situation the samebalanceAfter
could be counted for multiple reentrant calls.This is unlikely to happen because:
withdrawNonRestakedETH()
has access control viarestricted
noRestakingWithdrawalCredentials
points to a known contract
However error is authorisations in combination with
customDelegateCall()
could change this.Recommendation
To eliminate the risk, consider adding a
nonReentrant modifier
.Puffer
Due to refactoring
customDelegateCall()
was removed.
Informational25 findings
UnifiRewardsDistributor could use ReentrancyGuardTransient
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Contract
UnifiRewardsDistributor
usesReentrancyGuard
. However there is also a more gas efficient version avaible, using transient storage.Recommendation
Consider using ReentrancyGuardTransient.
No explicit error message in _processClaims()
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The assignment
uint256 amountToClaim = amounts[i] - claimedSoFar;
reverts whenamounts[i] < claimedSoFar
, without giving an explicit error message.Recommendation
Consider giving an explicit error message when
amounts[i] < claimedSoFar
.One solutions would be to use
saturatingSub()
in combination with the check foramountToClaim == 0
.EnumerableSet.UintSet not used
State
- Fixed
PR #54
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In contract
UniFiAVSManager
,EnumerableSet.UintSet
isn't used.Recommendation
Consider removing this line:
-using EnumerableSet for EnumerableSet.UintSet;
abi.encodeCall() can be used
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
_getAvsOperatorStatus()
usesabi.encodeWithSelector()
, which doesn't do checks so errors are not detected.Recommendation
Consider using
abi.encodeCall()
:import { IAVSDirectoryExtended } from "./interfaces/EigenLayer/IAVSDirectoryExtended.sol";` ...- abi.encodeWithSelector(bytes4(keccak256("avsOperatorStatus(address,address)")), address(this), operator)+ abi.encodeCall(avsOperatorStatus, (address(this), operator));...
Puffer
The reason we use
encodeWithSelector
is becauseavsOperatorStatus()
is not defined (in the interface) and we don't want to include the entirety of the AVS Directory interface.avsOperatorStatus() is deprecated
State
- Fixed
PR #56
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
According to a comment in AVSDirectoryStorage.sol,
avsOperatorStatus()
is deprecated:/// @notice Returns the registration status of each `operator` for a given `avs`./// @dev This storage will be deprecated once M2-based deregistration is removed.mapping(address avs => mapping(address operator => OperatorAVSRegistrationStatus)) public avsOperatorStatus;
Recommendation
Consider changing the code to be compatible with the situation where
avsOperatorStatus()
can no longer be called.Puffer
Updated the AVS to implement new methods of the upcoming EigenLayer Slashing upgrade as AVSDirectory is being deprecated.
Changes:
- Removed 2-step operator deregistration because operator deregistrations are now processed by EigenLayer regardless of the callback reverts or not
- Uses AllocationManager instead of AVSDirectory
- Implements OperatorSets required by the new EigenLayer slashing update
Unnecessary typecasts
State
- Fixed
PR #54
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Serveral typecasts in the constructor of
UniFiAVSManager
are not necessary.Recommendation
Consider changing the code to:
-AVS_DIRECTORY = IAVSDirectory(address(avsDirectoryAddress));+AVS_DIRECTORY = avsDirectoryAddress;-EIGEN_REWARDS_COORDINATOR = IRewardsCoordinator(address(rewardsCoordinatorAddress));+EIGEN_REWARDS_COORDINATOR = rewardsCoordinatorAddress;
Validators can't be registered again
State
- Fixed
PR #53
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
deregisterValidators()
keeps thevalidator.index
, which meansregisterValidators()
can't be done again for the samevalidator / validatorPubkey
, because its requires thevalidator.index
to be 0.Recommendation
Check if there is a usecase for using the same validator again. If so, change the code to allow this. Otherwise add a comment to make this clear foro future maintainers/readers
Puffer
Registering again is not allowed because that would allow an operator to increase its fees.
Creation of NonRestakingWithdrawalCredentials can be simplified
State
- Fixed
PR #20
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The creation of the
NonRestakingWithdrawalCredentials
contract is done viacustomDelegateCall()
and a factory, fromInstitutionalVault
and only has to be done once. This is a relatively complicated way.Additionally the
customDelegateCall()
is very powerfull and thus increased the risks.Recommendation
Consider creating an
initializeV2()
function withreinitializer(2)
to do the equivalent ofdeployNewStakingWithdrawalCredentials()
. Make sure to call this in one transaction with the contract upgrade to prevent frontrunning.Also consider removing
customDelegateCall()
.erc4626 compatibility
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
InstitutionalVault
doesn't fully comply to eip-4626.Recommendation
Consider doing the following:
- return 0 in the functions
maxDeposit() / maxMint() / maxWithdraw() / maxRedeem()
when the related functions are paused; - in functions
maxWithdraw()
/maxRedeem()
take into account the maximum available ETH that can currently be retrieved: e.g. should be maximized toWETH.balanceOf(address(this)) + (address(this).balance
and converted to shares formaxRedeem()
.
Puffer
Acknowledged. We are aware of this. The readme in the institutional repository says:
The vault is not 100% ERC4626 compliant to the interface as it accounts for assets that are not in the Vault, but it is not a problem for the current use case.
__deprecated_withdrawer doesn't require a value
State
- Fixed
PR #26
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In function
InstitutionalVault()
,__deprecated_withdrawer
is assigned a value, however this isn't used.As this isn't used a value of 0 might be used as well. This could be more gas efficient and also makes clear the value isn't used.
Recommendation
Consider changing the code to:
-__deprecated_withdrawer: address(this)+__deprecated_withdrawer: address(0)
completeQueuedWithdrawals() and token address 0
State
- Fixed
PR #27
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
completeQueuedWithdrawals()
uses token address 0. This is fine if this token address isn't used, which is the case whenstrategy!= _BEACON_CHAIN_STRATEGY
.The related function
queueWithdrawals()
does indeed use_BEACON_CHAIN_STRATEGY
, howevercompleteQueuedWithdrawals()
doesn't check/enforce this is the case.If the wrong strategy is used, a revert will occur that might be difficult to trace.
Recommendation
Consider checking/enforcing that the only strategy used in
completeQueuedWithdrawals()
is_BEACON_CHAIN_STRATEGY
.completeQueuedWithdrawals() behaves differently than withdrawNonRestakedETH()
State
- Fixed
PR #27
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The function
completeQueuedWithdrawals()
andwithdrawNonRestakedETH()
are similar but behave differently in updating the accounting.withdrawNonRestakedETH()
decreasesnonRestakedValidatorsETH
, howevercompleteQueuedWithdrawals()
doesn't decreaserestakedValidatorsETH
.This can be corrected with
setValidatorsETH()
, but is would be more consistent to also do this incompleteQueuedWithdrawals()
.Recommendation
Consider decreasing
restakedValidatorsETH
incompleteQueuedWithdrawals()
, whenreceiveAsTokens[]==true
.requestEigenPodConsolidation() doesn't check array lengths
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
requestEigenPodConsolidation()
doesn't check array lengths, while most other functions do, which is inconsistent.Recommendation
Consider checking
srcPubkeys.length == targetPubkeys.length
.Puffer
Acknowledged. The transaction will revert somewhere down the line.
Incorrect comment in function requestEigenPodConsolidation()
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The function
requestEigenPodConsolidation()
has the comment:The remainder is donated to the vault and not refunded to the caller
However
EigenPod::requestConsolidation()
does refund the excess ETH.Recommendation
Consider changing the comment.
Puffer
The vault doesn't refund to the caller, although the EigenPod does return to the vault.
Incorrect comment in getWithdrawalCredentials()
State
- Fixed
PR #24
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In function
getWithdrawalCredentials()
the following comment is no longer valid:(this contract address)
Recommendation
Consider removing or changing the comment.
commit code is different than onchain code
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The commit for the review is: https://github.com/PufferFinance/Puffer-Preconf/commit/4346c76216a53ec7fd3059380e97dd7141f981d1
This goes from 24093199:
struct UniFiAVSStorage { ... uint64 deregistrationDelay; EnumerableSet.AddressSet allowlistedRestakingStrategies;}
To: 4346c762:
struct UniFiAVSStorage { ... uint64 deregistrationDelay; mapping(uint8 => uint256) _deprecated_bitmapIndexToChainId; mapping(uint256 => uint8) _deprecated_chainIdToBitmapIndex; EnumerableSet.AddressSet allowlistedRestakingStrategies; mapping(bytes validatorPubkey => ValidatorData validatorData) validators; mapping(uint256 validatorIndex => bytes validatorPubkey) validatorIndexes; mapping(address operator => OperatorData operatorData) operators;}
Which would be tricky because two mappings are inserted in between
deregistrationDelay
andallowlistedRestakingStrategies
and thus the storage layout would be shifted.However the onchain version already has these additional mappings. So when upgrading from that, it would be ok.
struct UniFiAVSStorage { ... uint64 deregistrationDelay; mapping(uint8 => uint256) bitmapIndexToChainId; mapping(uint256 => uint8) chainIdToBitmapIndex; EnumerableSet.AddressSet allowlistedRestakingStrategies;}
Recommendation
Consider adding an intermediate step in the commits to allow for traceability.
Spearbit
This isn't easily fixable on github because the PRs are already merged. Set to acknowledged.
Duplicate structs
State
- Fixed
PR #54
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The structs
OperatorCommitment
andOperatorDataExtended
are defined in bothIUniFiAVSManager
andDeprecatedOperatorData
. This is confusing for readers of the code.Recommendation
Consider renaming the version in
DeprecatedOperatorData
to incidate they are deprecated.Some functions can't be called via InstitutionalVault
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The
eigenPod
functionsrequestWithdrawal()
andrequestConsolidation()
can be called via theInstitutionalVault
.However the equivalent functions of
NonRestakingWithdrawalCredentials
:requestWithdrawal()
andrequestConsolidation()
have to be called directly on theNonRestakingWithdrawalCredentials
contract. This is inconsistent.Recommendation
Consider also adding functions in
InstitutionalVault
to call theNonRestakingWithdrawalCredentials
functions.Also consider renaming the functions to make clear for which underlying contract they are meant because they have similar names.
Collisions with concatenation of dynamic types
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
requestConsolidation()
concatenates two dynamic types withbytes.concat()
. This allows for collisions.This could be abused to emit
ConsolidationRequested
instead ofSwitchToCompoundingWithdrawalCredentials
.Note: this is unlikely to occur because
requestConsolidation()
is authorized.Note: the comparable contract [EigenPod] does check the length of the Pubkeys.
Recommendation
Consider checking the length of
request.srcPubkey
andrequest.targetPubkey
.Puffer
An invalid consolidation request may still emit an event on the smart contract, but they simply wouldn't get accepted by the beacon chain. Technically. You can do a consolidation request from EOA for 2 random pubkeys (of legit length) and the tx would not revert. So from when we are dealing with contracts, you can trigger the events in the function even though nothing would really happen.
Additional emit in EigenPod::requestWithdrawal()
State
- Fixed
PR #22
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
NonRestakingWithdrawalCredentials::requestWithdrawal()
doesemit WithdrawalRequested
.However the comparable function
EigenPod::requestWithdrawal()
can also do an emitExitRequested
:// Emit event depending on whether the request is a full exit or a partial withdrawalif (request.amountGwei == 0) emit ExitRequested(pubkeyHash);else emit WithdrawalRequested(pubkeyHash, request.amountGwei);
Recommendation
Consider the usefulness of emitting
ExitRequested()
and consider adding it.Puffer
Added
indexed
as an alternative solution instead of creating a new event.No return of excess ETH
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The functions
EigenPod::requestWithdrawal()
andEigenPod::requestConsolidation()
send back excess ETH.However the comprable functions
NonRestakingWithdrawalCredentials::requestWithdrawal()
andNonRestakingWithdrawalCredentials::requestConsolidation()
don't do this.Recommendation
Consider also returning excess ETH.
Puffer
Acknowledged, we are dealing with dust amounts.
Unused errors
State
- Fixed
PR #54
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The error
CommitmentChangeNotReady()
is no longer used.Recommendation
Consider removing the unused error(s).
memory in function parameters
State
- Fixed
PR #55
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Several functions still use
memory
in the interface definition, while the implemenation usescalldata
. This is inconsistent.Additionally
setOperatorCommitment()
andupdateAVSMetadataURI()
still usememory
in the function parameters.Recommendation
Consider changing
setOperatorCommitment()
andupdateAVSMetadataURI()
to usecalldata
.Consider making the interface consistent with the implementation. Also inherit from the interface to make sure they are consistent:
-contract UniFiAVSManager is UniFiAVSManagerStorage, ... {+contract UniFiAVSManager is IUniFiAVSManager, UniFiAVSManagerStorage, ... {
Puffer
Incorrect comment in createVault()
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
A comment in
createVault()
says the return parameters are:{vault, access manager}
, however the real return parameters are{access manager, vault}
.Recommendation
Consider updating the comment.
Puffer
Acknowledged. The factory is already deployed on the mainnet.
Consider validate if the user is authorized to make custom delegate call
Description
When making
customExternalCall
, the code check if the action is prohibited.(bool isAllowed,) = IAccessManager(authority()).canCall(_msgSender(), target, bytes4(data));
However, the restricted user can bypass the restriction via
customDelegateCall
Recommendation
Consider add the validation
(bool isAllowed,) = IAccessManager(authority()).canCall(_msgSender(), target, bytes4(data));
to
customDelegateCall
as well.
Gas Optimizations3 findings
Variables could be cached
State
- Fixed
PR #54
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Several variables could be cached so save gas, provided there is no stack issue.
Recommendation
Consider caching the following:
- array lengths in for loops
- indexed variables in a for loops, that are used multiple times
- call to functions inside a for loop, that always give the same result, like
getMerkleRoot()
andgetWithdrawalCredentials()
Function registerClaimer() can be optimized
State
- Fixed
PR #54
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function registerClaimer can be optimized by taking actions outside the for loop.
Recommendation
Consider changing the code in the following way:
function registerClaimer(address claimer, PubkeyRegistrationParams[] calldata params) external {+ BLS.G1Point[] memory g1Points = new BLS.G1Point[](2);+ BLS.G2Point[] memory g2Points = new BLS.G2Point[](2);+ g1Points[0] = NEGATED_G1_GENERATOR(); for (uint256 i = 0; i < params.length; ++i) { ... - BLS.G1Point[] memory g1Points = new BLS.G1Point[](2);- g1Points[0] = NEGATED_G1_GENERATOR(); g1Points[1] = ... ...- BLS.G2Point[] memory g2Points = new BLS.G2Point[](2); g2Points[0] = ... g2Points[1] = ... ... }}
Function _startValidators() can be optimized
State
- Fixed
PR #26
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
In function
_startValidators()
, the conversion with_convertGweiToWei()
could be after the loop to save some gas.Recommendation
Consider changing the code in the following way:
uint256 totalAmount;for (uint256 i = 0; i < pubKeys.length; i++) {- totalAmount += _convertGweiToWei(amountsInGwei[i]);+ totalAmount += amountsInGwei[i];}+ totalAmount = _convertGweiToWei(totalAmount);