Organization
- @superform-xyz
Engagement Type
Spearbit Web3
Period
-
Repositories
Findings
Critical Risk
1 findings
1 fixed
0 acknowledged
High Risk
6 findings
6 fixed
0 acknowledged
Medium Risk
12 findings
12 fixed
0 acknowledged
Low Risk
19 findings
18 fixed
1 acknowledged
Informational
13 findings
9 fixed
4 acknowledged
Gas Optimizations
13 findings
13 fixed
0 acknowledged
Critical Risk1 finding
Malicious actor can overwrite other's user state via 1 wei vault share transfer to steal fund
Description
When transferring
SuperVaultshares transfer, the code copies the entire state from thefromaccount totoaccount.// called like this in _updateISuperVaultStrategy.SuperVaultState memory state = strategy.getSuperVaultState(from);strategy.updateSuperVaultState(to, state);The state contains redeem info and share price data.
struct SuperVaultState { uint256 pendingRedeemRequest; // Shares requested uint256 maxWithdraw; // Assets claimable after fulfillment uint256 averageRequestPPS; // Average PPS at the time of redeem request // Accumulators needed for fee calculation on redeem uint256 accumulatorShares; uint256 accumulatorCostBasis; uint256 averageWithdrawPrice; // Average price for claimable assets }Then the code allows the exploits below.
Scenario 1:
- Alice create account 1 and account 2.
- Alice have a fulfilled claim request, Alice deposit some assets to get 1 unit of share in account 1.
- Alice transfer the share to account 2 to clone the fulfillment request and double withdraw.
Scenario 2:
- Bob have a fulfillment state.
- Alice deposit some assets to get 1 unit of shares.
- Alice transfer the share to bob and bob's state get his entire state overwritten.
Both scenario leads to loss of fund.
Recommendation
Consider separate the
SuperVaultStateinto:-
SuperVaultSharesState: The average cost basis for a user's total shares. The code can track the accumulatorCostBasis. To be able to correctly compute averages, the code also need to store the baseline (accumulatorShares). -
SuperVaultRedeemState: A requestRedeem call can convert the ERC20 shares at the current user's cost basis into an state with a specific state that gets modified via cancel/fulfill/claim. (converted to pendingRedeemShares, pendingCostBasis).
Transferring shares should only affect the first state, updating the cost basis of the receiver as the average of their old position and the transferred shares at the avgCostBasisPerShare of
from.Reducing the
SuperVaultSharesStateof from by the transferred shares but keeping the cost basis per share the same.
High Risk6 findings
strategistHooksRoot permissions may be circumvented by any Strategist in favor of global permissions
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
noah.eth
Summary
The project team notes that vault creators/strategists who desires less permission for their vault may reduce the strategyProofs that their vault is allowed to execute hooks from via the
proposeStrategyHooksRootfunction inSuperVaultAggregator.The intention is to differentiate between what hooks a particular vault strategist may call from the globally enabled list of hooks. Globally, the list of permitted hooks is not finalized, however, the project team noted a rough idea can be seen in the
registerHooks()function inBaseTest.t.solwhere the test hooks are registered for the test SuperGovernor instance:function _registerHooks(Addresses[] memory A) internal returns (Addresses[] memory) { if (DEBUG) console2.log("---------------- REGISTERING HOOKS ----------------"); for (uint256 i = 0; i < chainIds.length; ++i) { vm.selectFork(FORKS[chainIds[i]]); SuperGovernor superGovernor = SuperGovernor(_getContract(chainIds[i], SUPER_GOVERNOR_KEY)); console2.log("Registering hooks for chain", chainIds[i]); if (DEBUG) { console2.log("deposit4626VaultHook", address(A[i].deposit4626VaultHook)); console2.log("redeem4626VaultHook", address(A[i].redeem4626VaultHook)); console2.log("approveAndRedeem4626VaultHook", address(A[i].approveAndRedeem4626VaultHook)); console2.log("deposit5115VaultHook", address(A[i].deposit5115VaultHook)); console2.log("redeem5115VaultHook", address(A[i].redeem5115VaultHook)); console2.log("requestDeposit7540VaultHook", address(A[i].requestDeposit7540VaultHook)); console2.log("requestRedeem7540VaultHook", address(A[i].requestRedeem7540VaultHook)); console2.log("approveAndDeposit4626VaultHook", address(A[i].approveAndDeposit4626VaultHook)); console2.log("approveAndDeposit5115VaultHook", address(A[i].approveAndDeposit5115VaultHook)); console2.log("approveAndRedeem5115VaultHook", address(A[i].approveAndRedeem5115VaultHook)); console2.log( "approveAndRequestDeposit7540VaultHook", address(A[i].approveAndRequestDeposit7540VaultHook) ); console2.log("approveErc20Hook", address(A[i].approveErc20Hook)); console2.log("transferErc20Hook", address(A[i].transferErc20Hook)); console2.log("deposit7540VaultHook", address(A[i].deposit7540VaultHook)); console2.log("withdraw7540VaultHook", address(A[i].withdraw7540VaultHook)); console2.log("approveAndRedeem7540VaultHook", address(A[i].approveAndRedeem7540VaultHook)); console2.log("swap1InchHook", address(A[i].swap1InchHook)); console2.log("swapOdosHook", address(A[i].swapOdosHook)); console2.log("approveAndSwapOdosHook", address(A[i].approveAndSwapOdosHook)); console2.log("acrossSendFundsAndExecuteOnDstHook", address(A[i].acrossSendFundsAndExecuteOnDstHook)); console2.log("fluidClaimRewardHook", address(A[i].fluidClaimRewardHook)); console2.log("fluidStakeHook", address(A[i].fluidStakeHook)); console2.log("approveAndFluidStakeHook", address(A[i].approveAndFluidStakeHook)); console2.log("fluidUnstakeHook", address(A[i].fluidUnstakeHook)); console2.log("gearboxClaimRewardHook", address(A[i].gearboxClaimRewardHook)); console2.log("gearboxStakeHook", address(A[i].gearboxStakeHook)); console2.log("approveAndGearboxStakeHook", address(A[i].approveAndGearboxStakeHook)); console2.log("gearboxUnstakeHook", address(A[i].gearboxUnstakeHook)); console2.log("yearnClaimOneRewardHook", address(A[i].yearnClaimOneRewardHook)); console2.log("ethenaCooldownSharesHook", address(A[i].ethenaCooldownSharesHook)); console2.log("ethenaUnstakeHook", address(A[i].ethenaUnstakeHook)); console2.log("cancelDepositRequest7540Hook", address(A[i].cancelDepositRequest7540Hook)); console2.log("cancelRedeemRequest7540Hook", address(A[i].cancelRedeemRequest7540Hook)); console2.log("claimCancelDepositRequest7540Hook", address(A[i].claimCancelDepositRequest7540Hook)); console2.log("claimCancelRedeemRequest7540Hook", address(A[i].claimCancelRedeemRequest7540Hook)); console2.log("cancelRedeemHook", address(A[i].cancelRedeemHook)); } // Register fulfillRequests hooks superGovernor.registerHook(address(A[i].deposit4626VaultHook), true); superGovernor.registerHook(address(A[i].redeem4626VaultHook), true); superGovernor.registerHook(address(A[i].approveAndRedeem4626VaultHook), true); superGovernor.registerHook(address(A[i].deposit5115VaultHook), true); superGovernor.registerHook(address(A[i].redeem5115VaultHook), true); superGovernor.registerHook(address(A[i].requestDeposit7540VaultHook), false); superGovernor.registerHook(address(A[i].requestRedeem7540VaultHook), false); // Register remaining hooks superGovernor.registerHook(address(A[i].approveAndDeposit4626VaultHook), true); superGovernor.registerHook(address(A[i].approveAndDeposit5115VaultHook), true); superGovernor.registerHook(address(A[i].approveAndRedeem5115VaultHook), true); superGovernor.registerHook(address(A[i].approveAndRequestDeposit7540VaultHook), true); superGovernor.registerHook(address(A[i].approveErc20Hook), false); superGovernor.registerHook(address(A[i].transferErc20Hook), false); superGovernor.registerHook(address(A[i].deposit7540VaultHook), true); superGovernor.registerHook(address(A[i].withdraw7540VaultHook), false); superGovernor.registerHook(address(A[i].approveAndRedeem7540VaultHook), true); superGovernor.registerHook(address(A[i].swap1InchHook), false); superGovernor.registerHook(address(A[i].swapOdosHook), false); superGovernor.registerHook(address(A[i].approveAndSwapOdosHook), false); superGovernor.registerHook(address(A[i].acrossSendFundsAndExecuteOnDstHook), false); superGovernor.registerHook(address(A[i].fluidClaimRewardHook), false); superGovernor.registerHook(address(A[i].fluidStakeHook), false); superGovernor.registerHook(address(A[i].approveAndFluidStakeHook), false); superGovernor.registerHook(address(A[i].fluidUnstakeHook), false); superGovernor.registerHook(address(A[i].gearboxClaimRewardHook), false); superGovernor.registerHook(address(A[i].gearboxStakeHook), false); superGovernor.registerHook(address(A[i].approveAndGearboxStakeHook), false); superGovernor.registerHook(address(A[i].gearboxUnstakeHook), false); superGovernor.registerHook(address(A[i].yearnClaimOneRewardHook), false); superGovernor.registerHook(address(A[i].cancelDepositRequest7540Hook), false); superGovernor.registerHook(address(A[i].cancelRedeemRequest7540Hook), false); superGovernor.registerHook(address(A[i].claimCancelDepositRequest7540Hook), false); superGovernor.registerHook(address(A[i].claimCancelRedeemRequest7540Hook), false); superGovernor.registerHook(address(A[i].cancelRedeemHook), false); // EXPERIMENTAL HOOKS FROM HERE ONWARDS superGovernor.registerHook(address(A[i].ethenaCooldownSharesHook), false); superGovernor.registerHook(address(A[i].ethenaUnstakeHook), true); superGovernor.registerHook(address(A[i].morphoBorrowHook), false); superGovernor.registerHook(address(A[i].morphoRepayHook), false); superGovernor.registerHook(address(A[i].morphoRepayAndWithdrawHook), false); superGovernor.registerHook(address(A[i].pendleRouterRedeemHook), false); } return A;}Finding Description
A Strategist can opt out of verifying strategy level permissions and benefit from looser global permissions by passing in empty
strategyProofarrays when callingSuperVaultStgrategy.executeHooks.Hook validation occurs on
SuperVaultAggregator.validateHookwhere a check occurs to see if both the global and strategy root vetoes have taken place:bool globalHooksVetoed = _globalHooksRootVetoed;bool strategyHooksVetoed = _strategyData[strategy].hooksRootVetoed;if (globalHooksVetoed && strategyHooksVetoed) { return false;}If a strategy veto took place or no veto took place, the validation defers to the calldata arguments to verify that one of the two proofs exist:
if (lengthGlobalProof == 0 && lengthStrategyProof == 0) { return false;}In our exploit scenario, the calling strategist may pass in the global proof only and circumvent any strategy level restrictions:
// First try to verify against the global root if providedif (lengthGlobalProof > 0 && !globalVetoed) { // Only validate against global root if it exists if (_globalHooksRoot != bytes32(0) && MerkleProof.verify(globalProof, _globalHooksRoot, leaf)) { return true; }}Not passing a strategy proof would be most simple but even passing a strategy proof that does NOT permit the
hookArgswould still succeed as the global check returns before the strategy specific check.Impact Explanation
Permissions the vault creator/main strategist believe they are enforcing are not enforced by the protocol. Global hooks such as ERC20 token transfers, when enabled globally, are available to every strategist for every vault.
Likelihood Explanation
The functionality is enabled as is and requires no unique circumstances to be exploitable. Any strategist may break out of their sandboxed permissions set by the
strategyProof.Recommendation
Reconsider what hooks are globally permitted.
When a
strategistHooksRootexists, disallow global proofs and enforce strategy level permissions.Controller and receiver cannot redeem shares after depositing fund to if receiver address differs from controller address
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
ladboy233
Description
the ERC20 shares are minted to
receiverbutSuperVaultStrategy.stateis created (with relevant accumulatorShares and cost basis for shares) forcontrollerstrategy.handleOperation(msg.sender, receiver, assets, shares, ISuperVaultStrategy.Operation.Deposit); _mint(receiver, shares);Then both
receiverandcontrollercannot redeem shares because- transaction revert when the code substract
accumulatorShareswhen the stragists fulfillreceiverredeem request.
function _calculateCostBasis( SuperVaultState storage state, uint256 requestedShares ) private returns (uint256 costBasis) { if (requestedShares > state.accumulatorShares) revert INSUFFICIENT_SHARES(); // Calculate cost basis proportionally costBasis = requestedShares.mulDiv(state.accumulatorCostBasis, state.accumulatorShares, Math.Rounding.Floor); // Update user's accumulator state state.accumulatorShares -= requestedShares; state.accumulatorCostBasis -= costBasis; return costBasis; }controllercannot create redeem request becausecontrollerhas no shares to transfer toescrowaddress.
Recommendation
controller(msg.sender) should only pay for the assets but thereceiverof both ERC20 shares and the corresponding state with the cost basis should be forreceiver.// use receiver_handleDeposit(receiver, assets, shares);Fee handling locks fees in SuperVaultAggregator
Severity
- Severity: High
Submitted by
noah.eth
Finding Description
Upkeep fees are transferred into the
SuperVaultAggregatorusing thedepositUpkeepfunction. The function records a balance for the strategist_strategistUpkeepBalance[strategist].When
forwardPPSis called, the balance may be reduced if the caller is not exempt. The strategist level accounting is updated but there is no transfer of funds out of the contract or a global accounting update to allow a fee recipient to pull the fees out ofSuperVaultAggregator.Recommendation
Update global accounting to record how many up tokens a fee recipient may claim, and access control a claiming function for them to call.
Lack of replay protection in PPS oracle
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
ladboy233
Description
ECDSAPPSOraclesignature schema does not have nonce and can be replayed once enough validator (more thanquorumRequirement) signs the price updateThe malicious user can push the outdated PPS price and break the share accounting in
SuperVaultandSuperVaultStragetyvia signature replay.// Create message hash with all parameters- If anyare incorrect, the message hash will be different and the// derived signer address will be incorrect- resulting in a revertbytes32 messageHash = keccak256(abi.encodePacked(strategy, pps, ppsStdev, validatorSet, totalValidators, timestamp));bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();The signature schema also lacks
block.chainidandaddress(this), then ifstrategyaddress is deployed to two blockchain,The malicious user can push the outdated PPS price and break the share accounting in
SuperVaultandSuperVaultStrategyvia cross-chain signature replay.Recommendation
- bytes32 messageHash = keccak256(abi.encodePacked(strategy, pps, ppsStdev, validatorSet, totalValidators, timestamp));+ bytes32 messageHash = keccak256(abi.encodePacked(strategy, pps, ppsStdev, validatorSet, totalValidators, timestamp, nonces++));Use
_domainSeparatorV4()to includeblock.chainidandaddress(this)in signature schema.Lack of hookAddress in merkle tree schema in SuperVaultAggregator.sol
Description
_createLeaf()double-hashes the calldata and ignores the hook address,so different hook address with identical encoded args share the same authorization leaf.
An example is that the the
RequestDeposit7540VaultHook.solhas the data format.function _buildHookExecutions( address prevHook, address account, bytes calldata data ) internal view override returns (Execution[] memory executions) { address yieldSource = data.extractYieldSource(); uint256 amount = _decodeAmount(data); bool usePrevHookAmount = _decodeBool(data, USE_PREV_HOOK_AMOUNT_POSITION); if (usePrevHookAmount) { amount = ISuperHookResult(prevHook).outAmount(); }the
RequestRedeem7540VaultHook.solhas the data format.and
//////////////////////////////////////////////////////////////*/ /// @inheritdoc BaseHook function _buildHookExecutions( address prevHook, address account, bytes calldata data ) internal view override returns (Execution[] memory executions) { address yieldSource = data.extractYieldSource(); uint256 shares = _decodeAmount(data); bool usePrevHookAmount = _decodeBool(data, USE_PREV_HOOK_AMOUNT_POSITION); if (usePrevHookAmount) { shares = ISuperHookResult(prevHook).outAmount(); }Both hook follow the data format: address + uint256
and Both hook have the same inspect function, which just packs the yield source address.
/// @inheritdoc ISuperHookInspector function inspect(bytes calldata data) external pure returns (bytes memory) { return abi.encodePacked(data.extractYieldSource()); }So a malicious caller can
- Use
RequestDeposit7540VaultHook.soldata to execute redeem action. - Use
RequestRedeem7540VaultHook.soldata to execute deposit action.
which leads to loss of fund.
Recommendation
The leaf should be created using hook address + hookArgs.
function _createLeaf(address hookAddress, bytes calldata hookArgs) internal pure returns (bytes32) { /// @dev note the leaf is just composed by the args, not by the address of the hook /// @dev this means hooks with different addresses but with the same type of encodings, will have the /// same authorization (same proof is going to be generated). Is this ok? return keccak256(bytes.concat(keccak256(abi.encode(hookAddress, hookArgs))));}- Use
Cancelled redeem requests make shares permanently unredeemable
Severity
- Severity: High
Submitted by
ethan
Summary
When a user cancels a pending redeem request, the deletion of the entire
superVaultState[controller]struct removes properties that are required for subsequent redeem requests. When the user later tries to redeem their shares again, the fulfillment of the request will always fail.Finding Description
When a user initially deposits assets, the shares and the assets associated with the deposit are stored in
superVaultState[controller].accumulatorSharesandsuperVaultState[controller].accumulatorCostBasisrespectively. These values can only be updated by way of SuperVaultStrategy's_handleDepositfunction.But if the user creates a redeem request and then cancels it,
_handleCancelRedeemdeletes the entiresuperVaultState[controller]struct before returning the shares back to the user from SuperVaultEscrow. This is a problem, because subsequent calls tofulfillRedeemRequeststo process this user's shares will revert with theINSUFFICIENT_SHARES()error after checking ifrequestedShares > state.accumulatorShares. And even if a partial fix restoredaccumulatorSharesalone, the zeroed-outaccumulatorCostBasiswould break the cost basis calculation, leading to incorrect fee and asset distribution.Impact Explanation
The only way for a user to redeem their shares is by way of this asynchronous
requestRedeemprocess. Users will be permanently unable to redeem their shares after having cancelled a previous request to redeem them.Likelihood Explanation
This will happen for any cancelled redeem request, so it's as likely to occur as users are likely to cancel a request for any reason.
Recommendation
Consider storing a separate struct with the details of the pending request, so that
_handleCancelRedeemcan easily restoresuperVaultState[controller].accumulatorShares,superVaultState[controller].accumulatorCostBasis, andsuperVaultState[controller].averageWithdrawPricewith the values they held prior to the redeem request. This pending struct can be deleted when the request is fulfilled.
Medium Risk14 findings
_toggleYieldSourceActivation can DOSed via 1 wei transfer
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
ladboy233
Description
The code strictly requires TVL owner share is 0.
if (IYieldSourceOracle(yieldSource.oracle).getTVLByOwnerOfShares(source, address(this)) > 0) { revert INVALID_AMOUNT();}Then a user can send 1 share to this contract to DOS the
_toggleYieldSourceActivationRecommendation
Consider remove the
getTVLByOwnerOfSharescheck.Anyone can reset the emergencyWithdrawable flag to false to block emergency action in SuperVaultStrategy
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
ladboy233
Description
timestamp >= emergencyWithdrawableEffectiveTime = 0is always true if there is no proposal.So anyone can reset
emergencyWithdrawable = false.Recommendation
Add the check
if (emergencyWithdrawableEffectiveTime == 0) revert NoProposal();VaultStrategy cannot be initialized because default feeConfig is used for validation
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
ladboy233
Description
The code check
feeConfiginstead offeeConfig_, which is alwaysfalseand block thestrategyinitialization.Recommendation
Check
feeConfig_instead offeeConfigwhen thestrategyis initializated.owner == controller should be enforced when handling redemption
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
ladboy233
Description
When redeeming,
the
ownertransfer the sharesescrow. However, the fulfillment request tries to reduce the controller'saccumulatorShares,if the
controllerdoes not have shares, the transaction revert.Recommendation
Enforce
owner == controllerwhen redeeming.Global vetoes are circumvented by strategist controlled calldata
Severity
- Severity: Medium
Submitted by
noah.eth
Finding Description
Hook validation returns false when both global and strategy level hooks are vetoed:
globalHooksVetoed && strategyHooksVetoed.Later the internal
_validateSingleHookvalidates against the global or strategy hook root depending on whether a veto exists and whether proof data has been provided.In the case of a global veto, validation defers to the strategy. The project has clarified that in the event
global is vetoed we would want to return false regardless if local is vetoed or not.Recommendation
Update the veto checks to also return false when
globalHooksVetoedalone is true.Malicious strategist can bypass the slippage protection
State
- New
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
ladboy233
Description
When hooks are executed in
executeHooks,This parameter serves as slippage protection.
bool usePrevHookAmount = _decodeHookUsePrevHookAmount(hook, hookCalldata);if (usePrevHookAmount && prevHook != address(0)) { vars.outAmount = _getPreviousHookOutAmount(prevHook); if (expectedAssetsOrSharesOut == 0) revert ZERO_EXPECTED_VALUE(); uint256 minExpectedPrevOut = expectedAssetsOrSharesOut * (BPS_PRECISION - _getSlippageTolerance()); if (vars.outAmount * BPS_PRECISION < minExpectedPrevOut) { revert MINIMUM_PREVIOUS_HOOK_OUT_AMOUNT_NOT_MET(); }}A malicious strategist can supply arbitrary
args.expectedAssetsOrSharesOutto disable the slippage protection and frontrun certain hook execution (swap execution) to extract fund.Same issue exists when the stragist executes
fulfillRedeemRequestsand_processSingleFulfillHookExecutionA malicious strategist can supply arbitrary
args.expectedAssetsOrSharesOutto disable the slippage protection to make user receives too few asset.if (vars.outAmount * BPS_PRECISION < expectedAssetOutput * (BPS_PRECISION - _getSlippageTolerance())) { revert MINIMUM_OUTPUT_AMOUNT_ASSETS_NOT_MET();}Recommendation
Charge deposit / collateral from
strategistaddress and slash them offline if thestrategistdoes execute transaction maliciously.Unsafe Set Mutation During Iteration in executeAddIncentiveTokens and executeRemoveIncentiveTokens
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
ladboy233
Description
Removing
tokenfrom the_proposedWhitelistedIncentiveTokensset while iterating over it causes the loop to terminate early, not every token in_proposedWhitelistedIncentiveTokenswill be iterated over.for (uint256 i; i < _proposedWhitelistedIncentiveTokens.length(); i++) { token = _proposedWhitelistedIncentiveTokens.at(i); _isWhitelistedIncentiveToken[token] = true; emit WhitelistedIncentiveTokensAdded(_proposedWhitelistedIncentiveTokens.values()); // Remove from proposed whitelisted tokens _proposedWhitelistedIncentiveTokens.remove(token); }Same issues exists in
executeRemoveIncentiveTokens()The code remove
tokenfrom_proposedRemoveWhitelistedIncentiveTokensinside the for loop.Recommendation
while (_proposedWhitelistedIncentiveTokens.length() != 0) { address token = _proposedWhitelistedIncentiveTokens.at(0); _isWhitelistedIncentiveToken[token] = true; _proposedWhitelistedIncentiveTokens.remove(token);}Adding test case to test
executeRemoveIncentiveTokenandexecuteAddIncentiveTokensis also highly recommended.Emergency mechanism for replacing malicious strategists is undermined by pre-existing strategist proposals
Severity
- Severity: Medium
Submitted by
ethan
Finding Description
Malicious primary strategists have wide latitude to exploit Superform users. The main mitigation for dealing with this scenario is SuperGovernor's
changePrimaryStrategistfunction, which allows the admin of SuperGovernor to swap the primary strategist of any strategy with a non-malicious account, bypassing the timelock and taking control of the strategy.However, the normal mechanism for updating the primary strategist — a timelocked proposal submitted by one of the strategy's secondary strategists — takes precedence over this emergency mechanism. If a proposal already exists when the admin calls
changePrimaryStrategist, it remains after the admin's change takes effect, and it can still be executed once the proposal's timelock expires.A malicious primary strategist can exploit this by populating the vault's set of secondary strategists with accounts they control, and then frontrunning the SuperGovernor's attempt to rescue the vault with a proposal that returns control of the strategy to the malicious strategist.
The SuperGovernor admin can call
changePrimaryStrategistagain once the malicious proposal has been executed, but the attacker only needs to maintain control long enough to drain user funds, add another account they control as a secondary strategist, and use that secondary strategist account to propose another of their accounts as the new primary strategist. So they too can repeat the process, and will have a new opportunity to exploit users every time they regain control of the strategy.Impact Explanation
Given the amount of damage a malicious strategist can do to users, and the lack of other mechanisms in the protocol to mitigate this potential damage, the impact of blocking the one clean recovery path for the strategy is substantial. The team also stated (after this issue was initially raised during the review) that this is a high-priority security concern.
Likelihood Explanation
The initial iteration of the exploit described above (frontrunning the first admin call to
changePrimaryStrategist) does not require specialized knowledge to execute successfully. Repeating the process requires slightly more effort, but remains straightforward.Recommendation
changePrimaryStrategistshould clear existing strategist proposals. Additionally, consider clearing all secondary strategists from the strategy as well, since they could be controlled by the malicious strategist and will retain the power to propose new strategists.Validators can fake the number of participants for a PPS transaction
Severity
- Severity: Medium
Submitted by
MiloTruck
Context:
Description:
ECDSAPPSOracle._validateProofs()does not checkvalidatorSetandtotalValidators, which represent the number of validators which signed for a PPS transaction and the total number of validators.As such, validators can collude and pass incorrect data for
validatorSetandtotalValidatorsto bypass the participation rate check inSuperVaultAggregator._forwardPPS():// C2.3) M/N Check: Check if enough validators participatedif (args.totalValidators > 0 && _strategyData[args.strategy].mnThreshold > 0) { // Calculate participation rate, scaled by 1e18 uint256 participationRate = (args.validatorSet * 1e18) / args.totalValidators; if (participationRate < _strategyData[args.strategy].mnThreshold) { checksFailed = true; emit StrategyCheckFailed(args.strategy, "INSUFFICIENT_VALIDATOR_PARTICIPATION"); }}The only requirement is that a sufficient number of validators must collude in order to pass quorum.
Recommendation:
Add a
validatorSet == validSignatureCountcheck inECDSAPPSOracle._validateProofs(). Additionally, the total number of validators can be fetched from the number of validators registered inSuperGovernor(i.e._validatorsList.length)Hooks which exclude arguments in inspect() must not be whitelisted
State
- New
Severity
- Severity: Medium
Submitted by
MiloTruck
Context:
Description:
In
SuperVaultAggregator, the hooks which strategists can execute are restricted by whitelisting the hook's arguments in a merkle tree:/// @notice Creates a leaf node for Merkle verification from hook arguments/// @param hookArgs The packed-encoded hook arguments (from solidityPack in JS)/// @return leaf The leaf node hashfunction _createLeaf(bytes calldata hookArgs) internal pure returns (bytes32) { /// @dev note the leaf is just composed by the args, not by the address of the hook /// @dev this means hooks with different addresses but with the same type of encodings, will have the /// same authorization (same proof is going to be generated). Is this ok? return keccak256(bytes.concat(keccak256(abi.encode(hookArgs))));}More specifically, arguments returned by the hook's
inspect()function must be a leaf in the merkle tree for it to be executable.However, there are hooks that must never be whitelisted because they have arguments that cannot be known ahead of time. Using
Swap1InchHookas an example, consider a Uniswap V2/V3 swap.inspect()returns the receiver (to), input token (token) and pool address (dex):if (selector == I1InchAggregationRouterV6.unoswapTo.selector) { (Address to, Address token,,, Address dex) = abi.decode(txData_[4:], (Address, Address, uint256, uint256, Address)); packed = abi.encodePacked(to.get(), token.get(), dex.get());} else if (selector == I1InchAggregationRouterV6.swap.selector) {More importantly, the
amountandminReturnparameters are excluded as it's not possible to hardcode the amount to swap and receive. IfSwap1InchHookwas whitelisted, a strategist could steal funds by performing a swap withminReturn = 0(i.e. no slippage) and sandwiching the swap transaction.As such, not all Superform hooks are safe to be whitelisted for strategists to execute.
Recommendation:
When whitelisting hooks, identify hooks which exclude arguments in
inspect()and avoid whitelisting them. The current protections do not guarantee that a strategist cannot rug for all Superform hooks.Multiple bundler IDs can point to the same address in BundlerRegistry
Severity
- Severity: Medium
Submitted by
MiloTruck
Context: BundlerRegistry.sol#L12-L13
Description:
BundlerRegistrystores bundler data in two mappings:- Each bundler ID points to an address.
- Each address points to the data for a bundler.
mapping(address bundlerAddress => Bundler bundlerData) public bundlers;mapping(uint256 bundlerId => address bundlerAddress) public bundlerIds;However, this design makes it possible for two IDs to point to the same
bundlerAddress, which is problematic as two IDs will point to the same data in thebundlersmapping.This has unintended side-effects, for example assume the following occurs:
- Call
registerBundler()to register two bundlers. This returns two IDs:id_1points to the address ofbundler1.id_2points to the address ofbundler2.
- Call
updateBundlerAddress()to updateid_2to point tobundler2. Now both IDs point to the same address. - Call
updateBundlerAddress()to updateid_2to point tobundler1again.
The data at
bundlers[bundler1]will be deleted, causingbundler1to be unregistered even thoughid_1still points to it.The following Foundry test demonstrates the example above:
// SPDX-License-Identifier: MITpragma solidity 0.8.28; import { Test } from "forge-std/Test.sol";import { BundlerRegistry } from "src/periphery/BundlerRegistry.sol"; contract BundlerRegistryTest is Test { function test_twoBundlersSameAddress() public { // Deploy BundlerRegistry BundlerRegistry bundlerRegistry = new BundlerRegistry(address(this)); // Bundler addresses address bundler1 = address(0x123); address bundler2 = address(0x456); // Register two bundlers bundlerRegistry.registerBundler(bundler1, ""); bundlerRegistry.registerBundler(bundler2, ""); // Get ID of bundler2 (uint256 id_2,,,) = bundlerRegistry.bundlers(bundler2); // Update ID 2 to point to bundler1 bundlerRegistry.updateBundlerAddress(id_2, bundler1); // bundler1 is registered assertTrue(bundlerRegistry.isBundlerRegistered(bundler1)); // Update ID 2 to point to bundler2 bundlerRegistry.updateBundlerAddress(id_2, bundler2); // bundler1 is no longer registered assertFalse(bundlerRegistry.isBundlerRegistered(bundler1)); }}Recommendation:
Consider storing bundler data with only one mapping. For example, if bundlers are identified by their address,
bundlerAddressshould be the identifier and bundler IDs should be removed.Issues with CREATE2 salt in SuperVaultAggregator.createVault()
Severity
- Severity: Medium
Submitted by
MiloTruck
Context: SuperVaultAggregator.sol#L128-L137
Description:
SuperVaultAggregator.createVault()deploys contracts with aCREATE2salt as the hash of their parameters and the current nonce:// Create minimal proxiessuperVault = VAULT_IMPLEMENTATION.cloneDeterministic( keccak256(abi.encodePacked(params.asset, params.name, params.symbol, currentNonce)));escrow = ESCROW_IMPLEMENTATION.cloneDeterministic( keccak256(abi.encodePacked(params.asset, params.name, params.symbol, currentNonce)));strategy = STRATEGY_IMPLEMENTATION.cloneDeterministic( keccak256(abi.encodePacked(params.asset, params.name, params.symbol, currentNonce)));However, there are two issues with this implementation:
- Using
abi.encodePacked()could cause the concatenation ofnameandsymbolcould collide. For example,abi.encodePacked("AA", "B")andabi.encodePacked("A", "AB")are the same. - Not including
msg.senderin the salt allows different users to deploy vaults/escrows/strategies to the same address.
This is problematic if a user batches a transaction to
createVault()with another transaction which interacts with the newly deployed contracts, since an attacker could front-run the vault deployment and change its parameters.For example:
- Bob batches two transactions:
- Calls
createVault()to deploy a vault withmainStrategistas himself. This deploys a vault at address0x123.... - Calls
SuperVault.deposit()to deposit into the vault at0x123....
- Calls
- Alice front-runs his transactions to call
createVault()with the same parameters, exceptmainStrategistis her address. - Alice's transaction deploys the vault at
0x123.... - When Bob's transactions are executed, he ends up depositing into the vault where Alice is the
mainStrategist, instead of himself.
Recommendation:
- Use
abi.encode()to pack arguments. - Include
msg.senderin the salt.
Incorrect rounding direction in SuperVault.convertToAssets()
Severity
- Severity: Medium
Submitted by
MiloTruck
Context:
Description:
According to the EIP-4626 specification,
convertToAssets()should round down:convertToAssets MUST round down towards 0.
However,
SuperVault.convertToAssets()rounds up instead:function convertToAssets(uint256 shares) public view override returns (uint256) { uint256 currentPPS = _getStoredPPS(); if (currentPPS == 0) return shares; return Math.mulDiv(shares, currentPPS, PRECISION, Math.Rounding.Ceil);}Recommendation:
Modify
convertToAssets()to round down. Additionally,previewMint()must be modified to still round up, since it callsconvertToAssets()currently.Signatures with zero nonce cannot be invalidated in SuperVault.invalidateNonce()
Severity
- Severity: Medium
Submitted by
MiloTruck
Context: SuperVault.sol#L295-L297
Description:
In
SuperVault,invalidateNonce()reverts if thenonceto invalidate isbytes32(0):function invalidateNonce(bytes32 nonce) external { if (nonce == bytes32(0) || _authorizations[msg.sender][nonce]) revert INVALID_NONCE(); _authorizations[msg.sender][nonce] = true;However,
authorizeOperator()does not explicitly check fornonce == bytes32(0), which means the zero nonce can be used for authorization signatures. As such, if a user generates a signature with a zero nonce and wants to cancel it later on, he will be unable to do so.Recommendation:
Remove the
nonce == bytes32(0)check frominvalidateNonce():function invalidateNonce(bytes32 nonce) external {- if (nonce == bytes32(0) || _authorizations[msg.sender][nonce]) revert INVALID_NONCE();+ if (_authorizations[msg.sender][nonce]) revert INVALID_NONCE(); _authorizations[msg.sender][nonce] = true;
Low Risk19 findings
type(IERC7540Operator).interfaceId should be supported in support interface check
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
According to ERC7540 spec,
https://eips.ethereum.org/EIPS/eip-7540
All asynchronous Vaults MUST return the constant value true if either 0xe3bc4e65 (representing the operator methods that all ERC-7540 Vaults implement) or 0x2f0a18c5 (representing the ERC-7575 interface) is passed through the interfaceID argument.
However, the if the interface id is
type(IERC7540Operator).interfaceId(0xe3bc4e65), thesupportInterfacecheck still returnfalseRecommendation
import
rc/vendor/standards/ERC7540/IERC7540Vault.soland
return
type(IERC7540Operator).interfaceIdmaxMint revert if pps oracle drops below 1.0
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
if
ppsdrops below 1.0, themaxMintfunction will revert as we compute "uint256.max / pps" which will overflow.Recommendation
maxMintcan return 2 ** 256 - 1 if there is no limit on the maximum amount of shares that may be minted.Escrow transfers should skip share price update
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
When transferring the share to escrow, the code should skip the share price update and the escrow only takes shares as custody and cannot start a redemption request.
Recommendation
- if (from != address(0) && to != address(0)) {+ if (from != address(0) && to != address(0) && to != address(escrow) && from != address(escrow)) { ISuperVaultStrategy.SuperVaultState memory state = strategy.getSuperVaultState(from); strategy.updateSuperVaultState(to, state);}super._update(from, to, value);SuperVaultStrategy is not capable of handling native token transfer
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
When process hook execution, the caller can optionally specify the native ETH value.
(vars.success,) = vars.executions[j].target.call{ value: vars.executions[j].value }(vars.executions[j].callData);But the
SuperVaultStragetysmart contract cannot receive native ETH and the functionexecuteHooksis not marked as payable.Then the strategist cannot forward ETH when executing the hook.
Recommendation
Mark
executeHooksas payble.Add
receive()function toSuperVaultStragetysmart contract should receive ETH after the hook execution.receive() external payable { }No SuperVaultAggregator.forwardPPS validation on updateAuthority and args.timestamp
Severity
- Severity: Low
Submitted by
noah.eth
Finding Description
The only validation on
updateAuthorityandtimestampare to determine if upkeep fee is required. This means:- out of order updates may be made (earlier timestamps to replace later ones)
- anyone can call
If the replay protection issue is addressed, this issue requires validator misbehavior or nonces that are not monotonically increasing.
Recommendation
Validate both parameters ensuring timestamp only increases and callers are among those expected.
SuperVault is not compliant with specification during paused state
Severity
- Severity: Low
Submitted by
noah.eth
Finding Description
maxMintandmaxDepositdo not account for paused state on the strategy (max is 0 while paused).Recommendation
Return 0 when the strategy is paused.
Many ERC20 tokens not supported - fee on transfer, cUSDCv3, etc.
State
- Acknowledged
Severity
- Severity: Low
Submitted by
noah.eth
Finding Description
The protocol assumes a standard ERC20 without unusual behavior such as fee on transfer, transferring of amounts that differ from calldata arguments (
cUSDCv3), etc.In the event one of these tokens are used, severe issues and loss of funds will occur.
Recommendation
Document token assumptions and consider whether token whitelisting is desirable. In the event these non standard tokens need to be supported, changes to the token transferring and accounting is needed.
See weird-erc20 for more information.
Array length validation inconsistencies
Severity
- Severity: Low
Submitted by
ethan
Finding Description
Many functions across the protocol accept arrays as parameters, and each function checks that the array length of each parameter is equal to all other array parameters before iterating through any of them. There are a few instances where one of the array parameters is not validated (or a validation is not needed):
executeHooks(SuperVaultStrategy.sol#L137): omitsargs.hookCalldatavalidationbatchForwardPPS(SuperVaultAggregator.sol#L207): omitsargs.totalValidatorsvalidationfulfillRedeemRequests(SuperVaultStrategy.sol#L163): unnecessarily checks thatargs.controllers.length == controllersLength. SincecontrollersLengthis itself defined asargs.controllers.length, this check is not needed
Recommendation
The missing validations listed above should be added, and the unnecessary one should be removed.
Inconsistent exemption check between forwardPPS and batchForwardPSS
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
There is discrepancy checking the up fee can be Exempted when forward single PPS and batch forward PPS.
When a single PPS is forwarded via
forwardPPS, the function_isExemptFromUpkeepruns.// Check if the update is exempt from paying upkeepbool isExempt = _isExemptFromUpkeep(args.strategy, updateAuthority, args.timestamp); // Create a new ForwardPPSArgs struct with updated isExempt and upkeepCost_forwardPPS( ForwardPPSArgs({ strategy: args.strategy, isExempt: isExempt, pps: args.pps, ppsStdev: args.ppsStdev, validatorSet: args.validatorSet, totalValidators: args.totalValidators, timestamp: args.timestamp, upkeepCost: SUPER_GOVERNOR.getUpkeepCostPerUpdate() }));The function
_isExemptFromUpkeep- Check if upkeep payments are globally disabled in SuperGovernor
- Check if Update is exempt if it is stale
- Check if strategist is a superform strategist
- Check if the updateAuthority is in the authorized callers list
However, when
batchForwardPPSis called, the code only check if upkeep payments are globally disabled in SuperGovernor, and_isExemptFromUpkeepis not called.The checks below are missing when batch forward PPS
- Check if Update is exempt if it is stale
- Check if strategist is a superform strategist
- Check if the updateAuthority is in the authorized callers list
Recommendation
Call
_isExemptFromUpkeepwhen batch forward PPS price.Missing check for invalid SuperAsset
Severity
- Severity: Low
Submitted by
ethan
Finding Description
It is best practice (and the standard throughout the codebase) to validate that
addressparameters are notaddress(0), butsetSuperAssetManagerdoes not check the value of the user-suppliedsuperAsset.Recommendation
Update the existing address validation to include the
superAssetparameter:if (_superAssetManager == address(0) || superAsset == address(0)) revert INVALID_ADDRESS();SuperVault initialization and vault creation should revert if decimal query fails
Severity
- Severity: Low
Submitted by
ethan
Finding Description
When initializing SuperVault or calling
createVaulton SuperVaultAggregator,asset.tryGetAssetDecimalsdefaults to 18 if thestaticcalltotoken.decimalsdoes not succeed. It is unlikely that this call would fail, but it is plausible if, for example, thedecimalsgetter on the token contract is implemented with a non-standard function signature.If this were to fail, and the real decimal value was any number other than 18, much of the accounting in the vault would be incorrect.
Recommendation
Both functions mentioned above (SuperVault's
initializeand SuperVaultAggregator'screateVault) should revert if!success:(bool success, uint8 assetDecimals) = params.asset.tryGetAssetDecimals();if (!success) revert INVALID_DECIMALS();Missing validation of maxStaleness parameter
Severity
- Severity: Low
Submitted by
ethan
Description
The following functions set the
maxStalenessproperty from a caller-supplied value without validating it first:createVault(SuperVaultAggregator.sol#L113)setOracleMaxStaleness(SuperGovernor.sol#L288)setOracleFeedMaxStaleness(SuperGovernor.sol#L296)setOracleFeedMaxStalenessBatch(SuperGovernor.sol#L305)
If the
maxStalenessvalue is too low, every PPS update will be treated as stale. This will exempt the strategist from paying upkeep fees untilmaxStalenessis reset to an appropriate value.Recommendation
The protocol should define a
MIN_STALENESSvalue, and each of the above functions should require thatmaxStaleness > MIN_STALENESS.BundlerRegistry functions can be called on non-existent bundler IDs
Severity
- Severity: Low
Submitted by
MiloTruck
Context: BundlerRegistry.sol
Description:
The following functions do not check that the address returned by
bundlerIds[_bundlerId]is notaddress(0):getBundler()updateBundlerAddress()updateBundlerExtraData()updateBundlerStatus()
As such, it is possible to call these functions with a non-existent bundler ID, which ends up reading/writing to
bundlers[address(0)].Recommendation:
In the functions listed above, verify that
bundlerIds[_bundlerId] != address(0).Additionally, check that
_newAddress != address(0)inupdateBundlerAddress().SuperVaultAggregator._validateSingleHook() incorrectly assumes empty proofs are invalid
Severity
- Severity: Low
Submitted by
MiloTruck
Context: SuperVaultAggregator.sol#L989-L995
Description:
SuperVaultAggregator._validateSingleHook()assumes a hook is not in a merkle tree if the length of its proof is zero:uint256 lengthGlobalProof = globalProof.length;uint256 lengthStrategyProof = strategyProof.length; // If both proofs are empty, the hook is not allowedif (lengthGlobalProof == 0 && lengthStrategyProof == 0) { return false;}However, empty proofs are valid if a merkle root is the leaf itself (i.e. merkle tree with only one leaf node).
For example, if a strategy only has one (hook address, arguments) whitelisted, the
strategyRootwould simply be that leaf. However, the check shown above and thelengthStrategyProof > 0check later on reject empty proofs, so it wouldn't be possible to prove that the leaf exists.Recommendation:
Consider changing the parameters to:
bytes32[] calldata proof,bool isGlobalProofInstead of passing a global and strategy proof, pass one proofs array alongside a
boolto determine if that proof is for the global or strategy root.Fulfill hooks in SuperGovernor could be not in _registeredHooks
Severity
- Severity: Low
Submitted by
MiloTruck
Context: SuperGovernor.sol#L37-L39
Description:
SuperGovernormaintains two set of hooks,_registeredHooksand_registeredFulfillRequestsHooks:// Hook registryEnumerableSet.AddressSet private _registeredHooks;EnumerableSet.AddressSet private _registeredFulfillRequestsHooks;However, the current implementation is dangerous considering that
_registeredHooksshould always be a superset of_registeredFulfillRequestsHooks.registerHook()always reverts when adding a hook if it is already in the set. Similarly,unregisterHook()reverts when removing a hook if it isn't already in the set. This creates two issues.- If a hook is in
_registeredHooksbut not in_registeredFulfillRequestsHooks, trying to register it as a fulfill request hook reverts. - A hook can be in
_registeredFulfillRequestsHooksbut not_registeredHooksby callingunregisterHook()withisFulfillRequestsHook_ = falseon a fulfill request hook. This violates the invariant that_registeredFulfillRequestsHooksis always a subset of_registeredHooks.
Recommendation:
Consider just skipping emitting the event instead of reverting if a hook is/isn't in the set. For example:
function registerHook(address hook_, bool isFulfillRequestsHook_) external onlyRole(_GOVERNOR_ROLE) { if (hook_ == address(0)) revert INVALID_ADDRESS(); if (isFulfillRequestsHook_ && _registeredFulfillRequestsHooks.add(hook_)) { emit FulfillRequestsHookRegistered(hook_); } if (_registeredHooks.add(hook_)) { emit HookApproved(hook_); }}Additionally,
unregisterHook()doesn't need aisFulfillRequestsHook_parameter, it should just attempt to remove from both sets:function unregisterHook(address hook_) external onlyRole(_GOVERNOR_ROLE) { if (_registeredFulfillRequestsHooks.remove(hook_)) { emit FulfillRequestsHookUnregistered(hook_); } if (_registeredHooks.remove(hook_)) { emit HookUnregistered(hook_); }}Merkle roots are not cleared when unregistering hooks in SuperGovernor
Severity
- Severity: Low
Submitted by
MiloTruck
Context: SuperGovernor.sol#L402-L408
Description:
In
SuperGovernor, whenunregisterHook()is called to unregister a hook, the data stored for that hook insuperBankHooksMerkleRootsandvaultBankHooksMerkleRootsare not cleared.This creates a potential footgun where a hook is unregistered while it has merkle root data. If the same hook is registered again later on, the merkle root data is unexpectedly preserved. This is even more problematic if a hook has a pending merkle root update and is unregistered before the update is executed, since there is no way to cancel the update.
Recommendation:
In
unregisterHook(), delete the data insuperBankHooksMerkleRootsandvaultBankHooksMerkleRootsfor the hook being unregistered.EnumerableSet.AddressSet can be used for lists in SuperGovernor
Severity
- Severity: Low
Submitted by
MiloTruck
Context: SuperGovernor.sol#L54-L64
Description/Recommendation:
SuperGovernormaintains a mapping and array of addresses for validators, relayers and executors:// Validator registrymapping(address validator => bool isValidator) private _isValidator;address[] private _validatorsList; // Relayer registrymapping(address relayer => bool isRelayer) private _isRelayer;address[] private _relayersList; // Executor registrymapping(address executor => bool isExecutor) private _isExecutor;address[] private _executorsList;Consider using
EnumerableSet.AddressSetfor these three lists as it makes the related functionality a lot simpler.Additionally, the functions for removing a validator/relayer/executor iterate through the entire list to find the corresponding entry. This could cost too much gas and revert if the list is extremely large, resulting in DOS (although unlikely to occur).
ApproveAndGearboxStakeHook.inspect() wrongly excludes the token address
Severity
- Severity: Low
Submitted by
MiloTruck
Context:
Description:
In
ApproveAndGearboxStakeHook, theinspect()function does not include the address of the token being staked:function inspect(bytes calldata data) external pure returns (bytes memory) { return abi.encodePacked(data.extractYieldSource());}In contrast,
ApproveAndFluidStakeHookincludes thetokenaddress ininspect()function inspect(bytes calldata data) external pure returns (bytes memory) { return abi.encodePacked( data.extractYieldSource(), BytesLib.toAddress(data, 24) //token );}As such, when validating executions involving
ApproveAndGearboxStakeHook, thetokenaddress will not be validated and can be any arbitrary address.Recommendation:
Include the
tokenaddress in theinspect()function ofApproveAndGearboxStakeHook.share to asset ratio is 1:1 for convertToShares and convertToAssets when currentPPS == 0 but not for totalAssets
Severity
- Severity: Low
Submitted by
noah.eth
Finding Description
There's a difference in handling of share
sharetoassetratio in theconvertToShares/convertToAssetsfunctions vs thetotalAssetsfunction.convertToShares:if (currentPPS == 0) return assetsconvertToAssets:if (currentPPS == 0) return sharestotalAssets:Math.mulDiv(supply, currentPPS, PRECISION, Math.Rounding.Floor)(evaluates to 0 whencurrentPPSis 0)
Recommendation
Documenting the 0 handling behavior will assist integrators.
Returning
supplywhencurrentPPS == 0would be consistent withconvertToShares/convertToAssets. However, consider whetherconvertToShares/convertToAssetsreturning something other than 0 is desirable in the first place whenpps == 0as 0 may indicate a larger problem.
Informational13 findings
mintShares function is not used and can be removed.
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
mintSharesis not used, the share is direclty whendeposit / mintin the vault.Recommendation
Remove the function
mintSharesto save gas.Add reentrancy guard to hook execution
Severity
- Severity: Informational
Submitted by
noah.eth
Summary
Finding Description
SuperBankandVaultBankrely on the hooks to prevent reentrancy whereasSuperVaultStrategygoes further and adds a reentrancy guard toexecuteHooks.Recommended adding the same
nonReentrantprotection assuperform/coreis undergoing modifications and may not always be safe.UpDistributor.owner trust assumptions
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
noah.eth
Finding Description
Owner can increase/decrease claimable amounts.
If increased and after a user has already claimed, the user cannot claim a second time (bool for tracking claimed vs uint256 to track amount claimed).
If decreased prior to claiming, the user misses out on tokens they would have otherwise been able to claim.
Strategists may circumvent upkeep fees
Severity
- Severity: Informational
Submitted by
noah.eth
Finding Description
SuperVaultAggregatortracks anauthorizedCallersarray for each strategy. Any strategist may add accounts to this array:if (!isAnyStrategist(msg.sender, strategy)) revert UNAUTHORIZED_UPDATE_AUTHORITY()When checking for exemption from upkeep fees in
SuperVaultAggregator._isExemptFromUpkeep, calls originating from an authorized caller returntrue.This means that any strategist can exempt accounts they control from upkeep fees, circumventing the fee model.
Recommendation
Clarify the use of authorized caller array, if intended to permit who may call, a revision is needed. If intended to exempt Superform accounts from fees, access controls need to be adjusted to disallow Strategists from adding/removing accounts from the array.
Note that a strategist may act maliciously and add caller who expects to be receiving fees to the authorized caller array. They may even frontrun a transaction to do so.
The project team suggested noting accounts that should not be exempt as a potential solution. The following would be one way of doing so:
function _isExemptFromUpkeep( address strategy, address updateAuthority, uint256 timestamp) internal returns (bool){ ...snip... // Update is exempt if it is stale if (block.timestamp - timestamp > _strategyData[strategy].maxStaleness) { emit StaleUpdate(strategy, updateAuthority, timestamp); return true; } ...snip...+ // where `upkeepCallers` is a boolean mapping+ if (upkeepCallers[updateAuthority]) { return true} // Check if the updateAuthority is in the authorized callers list uint256 authCallerLength = _strategyData[strategy].authorizedCallers.length; for (uint256 i; i < authCallerLength; i++) { if (_strategyData[strategy].authorizedCallers[i] == updateAuthority) { return true; } } return false;}Consider a deadline that must pass before the owner may call UpDistributor.reclaimTokens
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
noah.eth
Finding Description
Disallowing reclaiming of tokens before a deadline passes would give Up token recipients assurances that their claims will not be prematurely clawed back.
Avoid assigning a secondary strategist role when calling changePrimaryStrategist
Severity
- Severity: Informational
Submitted by
noah.eth
Finding Description
_SUPER_GOVERNOR_ROLEmay replace the main strategist in case of emergency. Given an emergency is likely related to misuse, or loss of access to the account, adding the account automatically as a secondary strategist is not recommended.Instead, simply replace the main strategist and if adding them back in a secondary role is desired, the new main strategist may do so in a separate transaction.
Consider greater separation being main strategist and secondary strategist capabilities
Severity
- Severity: Informational
Submitted by
noah.eth
Finding Description
The main strategist may update merkle roots to enable new hooks and restrict the secondary strategists to make risky calls.
We recommend, similarly, disallowing secondary strategists from updating PPS Verification Thresholds.
If no change to access is made, consider
isAnyStrategistfor consistency with other functions.Consider ordered nonces
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
noah.eth
Finding Description
SuperVault.authorizeOperatoruses unordered nonces. While thedeadlineoffers some protection, setting and resetting of an authorization can be executed out of order in the case of a reorg or two txns bundled after reading from a public mempool.An out of order pair of setting / resetting transactions would leave the contract with an authorization where none is intended.
Consider DEADLINE_PASSED instead of TIMELOCK_NOT_EXPIRED
Severity
- Severity: Informational
Submitted by
noah.eth
Finding Description
DEADLINE_PASSED, or related error, more accurately describes what triggers this revert.Consider adding a manual pause function for strategies
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
ethan
Finding Description
A strategy is only pausable as a byproduct of the outcome of PPS update checks, which means strategies cannot be manually paused in an emergency (and would be automatically unpaused as soon as a valid price update occurs). It is worth considering a manual pause function, accessible by the primary strategist, which would not be automatically reverted in
_forwardPPS. This could be useful if, for example, the primary strategist believes the PPS validators are colluding against the strategy.This would require either an additional pause property on the strategy (e.g.
isPausedandppsUpdatesPaused) or a reworking of the logic in_forwardPPSto skip paused strategies entirely.Incorrect effective time check in SuperGovernor
Severity
- Severity: Informational
Submitted by
MiloTruck
Context:
Description:
In
SuperGovernor,executeAddIncentiveTokens()andexecuteRemoveIncentiveTokens()check that the effective time for a proposal has passed as such:if ( _proposedAddWhitelistedIncentiveTokensEffectiveTime != 0 && block.timestamp < _proposedAddWhitelistedIncentiveTokensEffectiveTime) revert TIMELOCK_NOT_EXPIRED();if ( _proposedRemoveWhitelistedIncentiveTokensEffectiveTime != 0 && block.timestamp < _proposedRemoveWhitelistedIncentiveTokensEffectiveTime) revert TIMELOCK_NOT_EXPIRED();However, these checks are incorrect as they allow both functions to be called when there is no proposal.
Recommendation:
Both checks should be:
if ( _proposedAddWhitelistedIncentiveTokensEffectiveTime == 0 || block.timestamp < _proposedAddWhitelistedIncentiveTokensEffectiveTime) revert TIMELOCK_NOT_EXPIRED();BaseHook doesn't implement the inspect() function
Severity
- Severity: Informational
Submitted by
MiloTruck
Context: SuperVaultStrategy.sol#L1031-L1033
Description:
When validating hooks in
SuperVaultStrategy._validateHook(), it calls the hook'sinspect()function:return _getSuperVaultAggregator().validateHook( address(this), ISuperHookInspector(hook).inspect(hookCalldata), globalProof, strategyProof);This means all hooks to be used in strategies must implement an
inspect()function.However,
BaseHookdoes not implement theinspect()function, so new hooks created in the future may accidentally miss this function.Recommendation:
Refactor
BaseHookto include theinspect()function.Minor code improvements
Severity
- Severity: Informational
Submitted by
MiloTruck
Context: See below.
Description/Recommendation:
-
ECDSAPPSOracle.sol#L148-L149 -
validSignatureCountwill always be equal toproofs.lengthafter the for-loop, there's no need for a separate variable. In the for-loop,validSignatureCountis equal toi. -
SuperVault.sol#L28 - Since
SuperVaultis a proxy, it should inherit the upgradeable versions of the OpenZeppelin contracts (i.e.ERC20UpgradeableandReentrancyGuardUpgradeable). Additionally, consider inheritingEIP712Upgradeablefor EIP-712 related logic, instead of writing your own implementation. -
SuperGovernor.sol#L659-L668, SuperGovernor.sol#L697-L706 -
proposeVaultBankHookMerkleRoot()andproposeSuperBankHookMerkleRoot()are missing aproposedRoot != bytes32(0)check. -
SuperGovernor.sol#L626-L634 - In
executeUpkeepPaymentsChange(), consider resetting_proposedUpkeepPaymentsEnabledtofalsefor consistency. -
SuperGovernor.sol#L108 -
_SUPER_ASSET_FACTORYis a contract key and not a role. -
SuperGovernor.sol#L131-L134 - The constructor is missing a zero address check for
prover_.
Gas Optimizations13 findings
Duplicate signer check can be more efficient
Severity
- Severity: Gas optimization
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
When validating proofs, the code loops over the proof and extract signer address and looping over the valid signer count and validate there is no duplicate signer.
The nested for loop is not gas efficient if there are a lot of signers.
Recommendation
Consider validating if the signer is sorted by keeping track
lastSignerof to avoid nested for loop.address lastSigner; if (proofsLength == 0) revert ZERO_LENGTH_ARRAY(); // Process each prooffor (uint256 i; i < proofsLength; i++) { // Recover the signer from the proof address signer = ethSignedMessageHash.recover(proofs[i]); // Verify the signer is a registered validator if (!SUPER_GOVERNOR.isValidator(signer)) revert INVALID_VALIDATOR(); // Check for duplicates or improper ordering if (signer <= lastSigner) revert INVALID_PROOF(); lastSigner = signer; validSignatureCount++;}Use unchecked to save some gas
Severity
- Severity: Gas optimization
Submitted by
noah.eth
Finding Description
A check occurs above confirming
_strategistUpkeepBalance[msg.sender] >= amountmeaning the subtraction can be safely unchecked_strategistUpkeepBalance[msg.sender] -= amount.Save gas by avoiding duplicate hashing
Severity
- Severity: Gas optimization
Submitted by
noah.eth
Finding Description
The salt is the same for each of the 3
cloneDeterministiccalls. Gas can be saved by performing the hash operation one time and reusing the value.Avoid looping for lookups by using a mapping or EnumerableSet
Severity
- Severity: Gas optimization
Submitted by
noah.eth
Finding Description
When adding, removing, or checking for the presence of an account in the
authorizedCallersarray, a loop is used to inspect each element.Save gas, especially when checking for exemptions in
_isExemptFromUpkeepby using a mapping orEnumerableSetto avoid loop with multiplesloads.Considering making superGovernor an immutable variable in the implementation contract
Severity
- Severity: Gas optimization
Submitted by
noah.eth
Finding Description
superGovernoris not changed once initialized and would be more efficient as an immutable value in the implementation contract rather than initializing in storage on the proxy.Save gas by skipping 0 value storage write
Severity
- Severity: Gas optimization
Submitted by
noah.eth
Finding Description
Keeping the comment but skipping the 0 value storage write will save gas as the uninitialized value is already 0.
Do not duplicate address in storage and instead cast as IERC20 when needed
Severity
- Severity: Gas optimization
Submitted by
noah.eth
Finding Description
Having both
vaultandsharesread from storage each timeescrowSharesorreturnSharesis called reads the same data from storage twice. Being in two separate storage slots also means both are cold reads resulting in unnecessary gas.Store only one of them and cast as needed. Casting an address as IERC20 incurs no gas cost.
EnumerableSet already performs a existence check in the remove function
Severity
- Severity: Gas optimization
Submitted by
noah.eth
Finding Description
removealready performs the check and simply returns false if address is not present. See snippet below// We cache the value's position to prevent multiple reads from the same storage slotuint256 position = set._positions[value]; if (position != 0) { ...snip... return true;} else { return false;}Modifying to remove the conditional will save some gas
- if (_strategyData[strategy].secondaryStrategists.contains(newStrategist)) { _strategyData[strategy].secondaryStrategists.remove(newStrategist);- }Save gas by caching values instead of re-reading from storage
Severity
- Severity: Gas optimization
Submitted by
noah.eth
Finding Description
Events emitting newly stored storage values can save gas by caching the value before writing and using to both write to storage and emit the event.
Parameter should be validated early in function logic
Severity
- Severity: Gas optimization
Submitted by
ethan
Finding Description
The validation of
expectedAssetOutputcan be moved to the top of_processSingleFulfillHookExecution(or even earlier in thefulfillRedeemRequestsloop, alongside the hook validation) for some gas savings in the event thatexpectedAssetOutput == 0.Use strategiesLength variable to validate the lengths of the other parameters
Severity
- Severity: Gas optimization
Submitted by
ethan
Finding Description
args.strategies.lengthis used to validate the lengths of all other arrays inargs, but the variablestrategiesLengthis only declared after these four checks occur. Moving the declaration ofstrategiesLengthabove these validations, and using it instead of reading the length each time, will reduce the gas cost of the function.Duplicated checks can be removed
Severity
- Severity: Gas optimization
Submitted by
ethan
Finding Description
In two instances, there are duplicated validations across tightly coupled functions:
- SuperGovernor's
changePrimaryStrategistfunction validates thatnewStrategistis notaddress(0)before passing it onto SuperVaultAggregator, which implements the same check. - SuperVault's
cancelRedeemfunction validates that the quantity of shares in the controller's pending redeem request is not 0 before passing it onto SuperVaultStrategy, which implements the same check.
In both cases, the second function called in the chain can only be accessed by these user-facing entry points. The first instance of each check should be removed, leaving the validation closest to the critical logic intact (in case additional entry points are ever added in the future).
Duplicate WhitelistedIncentiveTokensRemoved event emission inside for loop
State
Severity
- Severity: Gas optimization
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
for (uint256 i; i < _proposedRemoveWhitelistedIncentiveTokenslength(); i++) { token = _proposedRemoveWhitelistedIncentiveTokens.at(i); if (_isWhitelistedIncentiveToken[token]) { _isWhitelistedIncentiveToken[token] = false; emit WhitelistedIncentiveTokensRemoved(_proposedWhitelistedIncentiveTokens.values()); } // Remove from proposed whitelisted tokens to be removed _proposedRemoveWhitelistedIncentiveTokens.remove(token); }Note that the event
WhitelistedIncentiveTokensRemovedemits inside the loop, which is can be gas consuming.The same (shrinking) array is broadcast many times.
Recommendation
+ emit WhitelistedIncentiveTokensRemoved(_proposedRemoveWhitelistedIncentiveTokens.values());for (uint256 i; i < _proposedRemoveWhitelistedIncentiveTokens.length(); i++) { token = _proposedRemoveWhitelistedIncentiveTokens.at(i); if (_isWhitelistedIncentiveToken[token]) { _isWhitelistedIncentiveToken[token] = false;- emit WhitelistedIncentiveTokensRemoved(_proposedWhitelistedIncentiveTokens.values()); } // Remove from proposed whitelisted tokens to be removed _proposedRemoveWhitelistedIncentiveTokens.remove(token); }