Organization
- @puffer
Engagement Type
Cantina Reviews
Period
-
Researchers
Findings
Low Risk
11 findings
8 fixed
3 acknowledged
Informational
6 findings
6 fixed
0 acknowledged
Gas Optimizations
1 findings
0 fixed
1 acknowledged
Low Risk11 findings
An ETH or WETH donation can block setValidatorsETH()
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
An ETH or WETH donation can block
_accrueFees()and thussetValidatorsETH(), which is a known issue.As we understand from the project one of the reasons for this is to protect incorrect oracle updates, however this check isn't very effective for that.
Recommendation
Consider having a seperate sanity check on the values for
restakedValidatorsETHandnonRestakedValidatorsETHto prevent oracle mistakes.Then in the fee calculation: skip the fee calculation if the reward is outside of the bounds and don't revert.
Puffer
Acknowledged. The breaker is a deliberate halt, not a permanent block. If it trips, the admin clears it by raising the APR cap and reporting, or by removing the donated ETH/WETH via
rescueAnything()(independent of fee accrual, works for any donation size), so reporting can never be permanently blocked.We keep the revert rather than skipping the fee, skipping would advance the baseline past a genuine reward and forfeit that fee for good.
Cantina
Acknowledged.
100% fee cap could lead to division by 0
State
- Fixed
PR #41
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
A 100% fee cap could lead to division by 0.
The code allows:
- maxTotalFeeBps = 10_000
- sum recipient bps = 10_000
With these values
denom - numByBpscan be zero and themulDiv()offeeSharesreverts.Additionally a 100% fee is not practicle.
Recommendation
Consider lowering the fee cap and/or check for
denom - numByBpsbeing zero.getWithdrawalCredentials() can return address 0
State
- Fixed
PR #43
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
If
initializerV3()isn't run directly aftercreateVault(), thengetWithdrawalCredentials()will return 0 andstartNonRestakingValidators()uses empty credentials and then funds will not be able to be withdrawn.Because
createVault()is authorized the risk of this is low.Recommendation
Make sure to always call
initializerV3()directly aftercreateVault().Also consider adding a check in
getWithdrawalCredentials()to verifynoRestakingWithdrawalCredentialsis set.Unsafe type conversion
State
- Fixed
PR #42
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Several unsafe type conversions are done. These could truncate a value is the value is too large.
This won't be an issue in practice because there is not that many ETH. For comparison:
_increaseNetLPFlow()checks for overflows.Recommendation
Consider using
SafeCast.toUint128().depositScalingFactor is not explictly checked
State
- Fixed
PR #38
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The DelegationManager has a
depositScalingFactorwhich could interfere with the value ofscaledShares.Recommendation
Consider checking
depositScalingFactor()has the correct value.Larger withdrawals[] arrays will revert
State
- Fixed
PR #40
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The
tokens[]array is of size 1, but thewithdrawals[]array could be bigger. In that casecompleteQueuedWithdrawals()will revert because it tries to indextokens[]with the same values it indexeswithdrawals[].function completeQueuedWithdrawals(...) ... { uint256 n = withdrawals.length; for (uint256 i; i < n; ++i) { _completeQueuedWithdrawal(withdrawals[i], tokens[i], receiveAsTokens[i]); } }Recommendation
Consider making the array of
tokens[]the same size aswithdrawals[].Received ETH calculation in completeQueuedWithdrawals() might be inaccurate
State
- Fixed
PR #38
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Received ETH calculation in
completeQueuedWithdrawals()might be inaccurate if slashing has occured.Recommendation
Consider using the
balanceBefore/balanceAftermethod, similar towithdrawNonRestakedETH().Function completeQueuedWithdrawals() might revert if totalAmount is larger than restakedValidatorsETH
State
- Fixed
PR #37
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
completeQueuedWithdrawals()might revert iftotalAmountis larger thanrestakedValidatorsETH. This might happen in the oracle update isn't accurate.Recommendation
Consider using the saturating subtraction like
withdrawNonRestakedETH()does.Timing of setValidatorsETH
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The timing of
setValidatorsETHis important:- if it is in the same block as a function that increments/decrements
restakedValidatorsETH/nonRestakedValidatorsETH, it will overwrite these values, depending on the order; - staking ETH might be in transit for a while and
setValidatorsETHmight not take this into account.
This is also important because the fee calculation is not idempotent (e.g. losses don't have fees, so the order is relevent).
Recommendation
Possibly solutions:
- carefully timing the calls;
- include the inflight assets in the
setValidatorsETH()values; - revert in
setValidatorsETHifrestakedValidatorsETH/nonRestakedValidatorsETHare updated in the last N blocks and recalculate the values and try again.
Puffer
Acknowledged. The mitigation is operational that needs to be coordinated with oracle.
Cantina
Acknowledged.
Low values of totalAssets() might get stuck on require() in _accrueFees()
State
- Fixed
PR #36
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
If
totalAssets()ever reaches a low value, therequire()might revert becauserewardscould them be larger thanmaxRewards.Additionally the early return doesn't do an
emit.Recommendation
Consider removing the early return, so the standard
emitwill be used. Only do the require whentotalAssets()has a non trivial value. For example in the following way:function _accrueFees(Storage storage $) internal virtual { ...- if ($.lastTotalAssets == 0) {- $.lastTotalAssets = uint128(newTA);- $.netLPFlowSinceLastReport = 0;- $.lastReportTimestamp = uint64(block.timestamp);- return;- } int256 rewards = ... if (rewards > 0) { uint256 r = uint256(rewards); ...+ if ($.lastTotalAssets > 1 ether) // otherwise the require might be too strict require(r <= maxRewards, RewardsExceedSanityBound()); _mintFeeShares($, r, newTA); } $.lastTotalAssets = uint128(newTA); $.netLPFlowSinceLastReport = 0; $.lastReportTimestamp = uint64(block.timestamp); emit FeesAccrued(rewards, newTA);}Fee configuration changes can be applied to already-accrued Rewards
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
Fee recipient changes are applied to the current unreported reward period, even if the rewards were earned before the change. Fees are only minted when
setValidatorsETH()is called.If
10 ETHof rewards accrued while the recipient list was empty, the admin can set1000 bpsbefore the oracle report. The next report mints fee shares using the new1000 bps.As a result, the new recipient receives fees on rewards earned before the fee was configured.
The reverse is also possible: the admin can clear recipients before the report and avoid fees on already-accrued rewards.
Recommendation
Fee configuration changes should only apply to future rewards, not the current unreported period.
Consider the fix: Accrue fees before changing recipients or bps
Puffer
Acknowledged. Fee configuration (
setFeeRecipients(),setMaxTotalFeeBps(),setMaxAprBps()) is restricted to the trusted vault admin, who already holds strictly broader control over share supply and assets viaadminMint(),adminBurn(), andrescueAnything(). Any value obtainable by timing a fee-config change around an oracle report is a subset of those existing privileges and grants no additional capability.Cantina
Acknowledged.
Informational6 findings
Different ways to send ETH
State
- Fixed
PR #39
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Different ways to send ETH are used:
NonRestakingWithdrawalCredentials::withdrawETH()usesAddress.sendValue();InstitutionalVault::redeemETH()andInstitutionalVault::withdrawETH()usereceiver.call{...}().
This is inconsisent.
Recommendation
Consider using the same method everywhere.
Array lengths not checked
State
- Fixed
PR #40
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Array lengths in
completeQueuedWithdrawals()are not checked:Recommendation
Consider adding the following checks:
withdrawals.length == receiveAsTokens.lengthwithdrawals[i].strategies.length == 1withdrawals[i].scaledShares.length == 1
There is no function getLastReportTimestamp()
State
- Fixed
PR #39
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Several values can be retrieved, except from
lastReportTimestamp.Recommendation
Consider adding a function
getLastReportTimestamp()if this is useful.Could have a constant for 10_000
State
- Fixed
PR #41
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The value of
10_000is used several times. If this is ever updated than one location could forgotten.Recommendation
Consider having a constant for
10_000.Error NotEnoughETH not accurate
State
- Fixed
PR #39
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The error
NotEnoughETHis not accurate, because the check is done forWETH.Recommendation
Consider changing the error in the following way:
-NotEnoughETH+NotEnoughWETHComment for createVault() is incorrect
State
- Fixed
PR #39
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
A comment for
createVault()is incorrect, because the returned values are in the reverse order.@return The address of the vault and the address of the access managerRecommendation
Consider updating the comment and also consider using named return variables.
Gas Optimizations1 finding
For loops with array lengths can be optimized
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
For loops with array lengths can be optimized because in every loop iteration the length is calculated.
Recommendation
Consider caching the array length.
Puffer
Acknowledged. This is a negligible optimization: storage-array loops already cache the length, and for calldata arrays the per-iteration length read is cheap. We leave the loops as-is, also because caching it in startValidators would add a local that might trips a stack-too-deep in that function. Not worth changing.
Cantina
Acknowledged.