Hydrogen Labs: Liquid Staking
Cantina Security Report
Organization
- @hydrogen-labs
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Low Risk
5 findings
3 fixed
2 acknowledged
Informational
5 findings
3 fixed
2 acknowledged
Low Risk5 findings
Incorrect Return Values in maxDeposit() and maxWithdraw() break EIP-4626 Compliance
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Optimum
Description
The current implementation of several EIP-4626 view functions in
gVaultdoes not fully adhere to the EIP-4626 standard. The standard requires these functions to provide a highly accurate, real-time reflection of the vault's underlying assets and the maximum possible user interactions.Specifically, the following discrepancies were identified:
- Underestimation of
totalAssets(): According to the EIP-4626 specification,totalAssets()should reflect the total amount of managed underlying assets. This includes unclaimed staking rewards and should account for reward fees (though it should excluding withdrawal fees). Excluding these from the calculation leads to an undervalued share price and inaccurate reporting of the vault's TVL. - Inaccurate
maxFunctions:maxDeposit(),maxMint(),maxWithdraw(), andmaxRedeem()fail to account for:- Pause States: They do not return
0when the vault is paused (viaMagmaV2or internalgVaultmechanisms). - Global Caps: The return values do not reflect deposit/mint limits implemented in the
gVaultlogic. - Validator Status: The functions do not return
0if a validator is paused or removed, which halts operations.
- Pause States: They do not return
Recommendation
Update the EIP-4626 view functions to incorporate all internal logic and pending rewards:
totalAssets(): Modify the logic to include all accrued but unclaimed staking rewards and ensure reward-related fee logic is applied as per the specification.maxfunctions: Update these to return0instead of reverting during any condition that would cause a transaction failure (e.g., pauses, reaching the global cap, or inactive validator status).
Inaccurate Loop Validation in adminInitiateRebalanceBpsValId() Allows Skipping Account Processing
State
Severity
- Severity: Low
Submitted by
Optimum
Description
The function
adminInitiateRebalanceBpsValIdis designed to update user share balances in batches during a validator rebalance. The current validation logic for the batch range is incorrectly implemented:if (_start == _stop) revert ErrInvalidStop();This logic is flawed for two reasons:
-
Prevents Single-Account Batches: It reverts if an admin attempts to process a batch containing only one account
(_start == _stop), which is a valid operational case. -
Allows Skipping Logic: It fails to check if _start > _stop. Since the loop condition is i <= _stop, a start index greater than the stop index will cause the loop to skip entirely while still allowing the tracking index to advance via
$._lastRebalancedStartIndex[_valId] = _stop + 1;.
Recommendation
Update the validation to permit single-account batches and strictly forbid inverted ranges:
if (_start == _stop) revert ErrInvalidStop(); // With:if (_start > _stop) revert ErrInvalidStop();Incorrect Rounding Direction in undelegate Burns Fewer Shares Than Expected
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Arno
Description
In
gVault.sol, the share amount to burn for an asset-specified withdrawal is rounded down,Per ERC4626, forwithdraw/previewWithdraw-style flows (user specifies assets), the required shares should be rounded up so the user burns the maximum shares for the requested assets (favoring the vault). UsingFloorcan under-burn shares, leaking value to withdrawers at the expense of remaining share holders (and can create edge-case “dust”/zero-share burns).Recommendation
Use
Math.Rounding.Ceilinstead ofMath.Rounding.Floorwhen computing shares to burn on withdrawal:uint256 _shares = _sharesForAssets(_valId, _amount, Math.Rounding.Ceil);gVault: pauseValId()and unpauseValId() should only be allowed to be called for active validators
State
Severity
- Severity: Low
Submitted by
Optimum
Description
Both
pauseValId()andunpauseValId()can be called for non-active validators (non existent/paused/removed) which might not happen in reality since it is an owner only function but is not accurate in terms of the expected state machine transitions.Recommendation
Short term: Consider reverting in case the validator is not marked in the
_isWhitelistedmapping.Long term: consider fully implementing the recommendation of Finding 8.
Unconditional rewards-claim timestamp update bypasses overdue guard
State
Severity
- Severity: Low
Submitted by
Arno
Description
In
claimAndCompoundRewards()the contract updates the global rewards-claim timestamp unconditionally, even if no validator rewards were actually claimed (e.g., all validators are paused or otherwise skipped in the loop)delegate()andundelegate()call_checkRewardsClaimDelay(), which only reverts if the time since_lastRewardsClaimTimestampexceeds_maxRewardsClaimDelay(default 7 days). Because the timestamp can be refreshed without claiming, the “rewards overdue” protection becomes ineffective and delegations/undelegations can continue while rewards accumulate but remain unclaimed.Recommendation
Only call
_updateLastRewardsClaimTimestamp()if rewards were actually claimed (e.g., trackclaimedAny/claimedAllEligibleinside the loop)
Informational5 findings
Code Quality & Maintainability
State
Severity
- Severity: Informational
Submitted by
Optimum
Description
- gVault.sol: L187:
_isRebalanceBatchCompleted()should be used here instead. _isRebalanceBatchCompleted(): Will returntruein case a rebalance never started, the name of the function does not reflect that. You might want to change the name toisRebalanceInactive()._isRebalanceBatchCompleted(): the second part of the condition$._lastRebalancedStartIndex[_valId] != $._accountsByValidator[_valId].lengthis redundant and should be removed.- Remove code comments about
multiplierfrom thegVaultcontract. - typo in gVault.sol: L318 in the word "funcionality"
- Missing natspec documentation for the
_magmaSharesparameter that was added to many functions in thegVaultcontract. - gVault.sol: L335: The comment of "// this cannot be 0, checked at checkMagmaShares modifier," is wrong since this check is missing from
checkMagmaShares. - Consider renaming
adminInitiateRebalanceBpsValId()toadminRebalanceInitiateBpsValId()so it will resemble theadminRebalanceInitiate()function ofCoreVaultwhich will make it easier to navigate in the code, you might want to also changeCore.redelegateToValidators()toadminCompleteRebalanceor some shared name for both contracts.
BaseVault._claimValidatorRewards() Missing RewardsClaimed Event Emission
State
Severity
- Severity: Informational
Submitted by
Optimum
Description
The internal function
_claimValidatorRewards()inBaseVaultis responsible for processing and collecting rewards from validators. However, the function does not emit theRewardsClaimedevent upon a successful claim. While the state is updated correctly, the lack of an event makes it difficult for off-chain infrastructure to track these actions.Recommendation
Add an
emit RewardsClaimed(...)statement within the_claimValidatorRewards()function. Note on Implementation: Given that this is a non-critical improvement and does not violate any EIP standards, an immediate contract upgrade or migration may be considered "overkill." It is recommended to include this event emission in the next scheduled deployment or major architectural upgrade to improve the long-term observability of the protocol.BaseVault: Consider refactoring how validator state machine is stored in storage
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Optimum
Description
BaseVault,CoreVaultandgVaultimplement a state machine that represents the life-cycle of validators in Monad. In the current version of the code, the state is stored in multiple different variables in the storage, which makes it harder to maintain and prone to errors like we have seen in Finding 6.Originally,
BaseVaultis using the_validatorStatusmapping that maps a validator id to the following states:enum ValidatorStatus { NONE, PAUSED, REMOVED, UNDELEGATING }However, this enum lacks many states that are actually implemented in the code, such as:
- ACTIVE state (or ADDED): After adding a validator (when
_registerValidator()is called),_validatorStatusis set toNONE(0) for that validator which is redundant, instead of setting it to ACTIVE._isWhitelistedis an auxiliary mapping that is used here instead but should be removed in case you are adding the ACTIVE state instead. - PAUSED state is ambiguous:
BaseVaultuses_validatorStatusto store thePAUSEDwhen initiating a removal of a validator, whilegVaultis also using the paused terminology (managed by thePausableValIdcontract) but it is used for rebalancing. You might want to change PAUSED to PAUSED_FOR_REMOVAL and introduce a new state that is originally meant forgVaultonly calledPAUSED_FOR_REBALANCINGbut can and should actually be used forCoreVaultas well insideadminRebalanceInitiate().
Note on Completeness: The states identified above represent the immediate gaps in the current implementation; however, there might be more states needed for the protocol's specific edge cases—please take the time to verify the full lifecycle before finalization.
gVault.adminCompleteRebalance() Lacks an explicit "paused" check
State
Severity
- Severity: Informational
Submitted by
Optimum
Description
Although we could not find a concrete issue in the current code, we think it is better to add a check that restricts access to the
adminCompleteRebalance()like the one that's implemented inadminInitiateRebalanceBpsValId().Recommendation
Short term: Consider implementing the same check of
whenPausedValId.Long term: Consider improving the state machine management in storage as described in Finding 8.
Asymmetric Gas Griefing via Unbounded Rebalance Loop
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Optimum
Description
The
gVaultcontract employs a "Push" rebalancing model inadminInitiateRebalanceBpsValIdthat requires the admin to manually iterate through every account in_accountsByValidator. While batching via_startand_stopparameters allows the admin to stay within block gas limits per transaction, the architectural design creates a permanent, asymmetric vulnerability to gas griefing.The Asymmetric Cost Risk
- Low Entry Cost for Attacker: An attacker can "stuff" the array by depositing the
_minUserDepositAmount(currently 100 MON) across numerous unique addresses. This is a one-time capital commitment to create a permanent operational burden on the owner. - High Recurring Cost for Admin: Because the admin must process the array sequentially, every "dust" account created by the attacker adds mandatory
SLOADoperations and an expensiveSSTOREto update_magmaSharesForUserByValidator. - Ineffectiveness of Pruning: A malicious actor will never undelegate. Their addresses remain in the storage array indefinitely, as pruning only triggers on voluntary exits (balance reaching zero).
- Sequential Locking:
adminCompleteRebalanceis strictly locked and will revert unless_lastRebalancedStartIndexexactly matches the total array length. The admin is forced to spend gas to update negligible balances just to unlock the validator's capital and forward funds to theCoreVault.
Impact: The Frequency Multiplier
The severity of this impact is directly tied to the frequency of rebalancing:
- Operational "Tax": The cost of a rebalance is incurred every single time the admin initiates the process. While a single batch may be affordable, the cumulative cost of processing thousands of "junk" accounts across multiple rebalances per year creates a significant drain on the protocol's operational budget.
- Cumulative Delay: Beyond the gas cost, the frequency of rebalancing compounds the risk of capital inefficiency. Each rebalance puts funds in a
pendingRedelegateByValidatorstate (earning 0% rewards). High-frequency rebalancing under an "array stuffing" attack means validator funds spend a disproportionate amount of time earning zero yield while waiting for the admin to clear the "dust" batches.
Recommendation
- Gas Benchmarking: The team should conduct gas tests to measure the cost of
adminInitiateRebalanceBpsValIdwhen processing a validator with 1,000+ addresses. Specifically, compare the gas limit consumed by an iteration that performs anSSTOREversus one that only performs anSLOAD. - Dust Threshold Skip: Modify the loop in
adminInitiateRebalanceBpsValIdto allow the admin to skip addresses where_magmaSharesForUserByValidatoris below a certain "dust" threshold (e.g., 0.1 MON). This eliminates the expensiveSSTOREgas cost for poisoned accounts, significantly reducing the recurring tax and allowing for larger, faster batches. - Entry Barrier Review: Re-evaluate if the
_minUserDepositAmountprovides a sufficient economic barrier to deter an attacker from adding thousands of addresses to the state.