Organization
- @Fastlane
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
High Risk
3 findings
3 fixed
0 acknowledged
Medium Risk
8 findings
6 fixed
2 acknowledged
Low Risk
8 findings
6 fixed
2 acknowledged
Informational
17 findings
11 fixed
6 acknowledged
High Risk3 findings
externalReward() calls don't consider max external reward
State
- Fixed
PR #616
Severity
- Severity: High
Submitted by
Riley Holterhus
Description
The
_settleValidatorRewardsPayable()function settles therewardsPayablethat a validator has accumulated from_handleValidatorRewards()calls in the previous internal epoch. The main function it uses is the staking precompile'sexternalReward()function.The staking precompile reverts if the external reward exceeds a maximum allowed value. Monad's public docs currently reference 1,000,000 MON as the limit, however the codebase now appears to have a different threshold of 10,000,000 MON.
The ShMonad codebase does not account for this maximum. If a validator's
rewardsPayableexceeds the precompile limit, theexternalReward()call will revert and_handleRewardsRedirect()will run as a fallback. This causes the entire reward to be diverted to shMON holders, and the validator and its external delegators miss out on the rewards that were supposed to be distributed to them.Because
rewardsPayablerepresents MEV rewards, it may be possible for a rare MEV event to naturally trigger this scenario. Also, note that anyone can callsendValidatorRewards()to donate MON and increaserewardsPayablefor a validator. Since_handleRewardsRedirect()immediately increases the shMON exchange rate (with no revenue smoothing), users may have an incentive to donate rewards to push a validator above the limit and cause a redirect.Note that the same issue applies to the
externalRewards()call in theCoinbasecontract.Recommendation
Add logic to ensure
externalReward()calls respect the upper limit. One possible approach is to split largerewardsPayableamounts into multipleexternalReward()calls. With this idea, one implementation decision to consider is whether the partial payments are executed in one main loop or spread into future epochs, and this decision may lead to other considerations.Fastlane
Fixed in PR 616, PR 634, and PR 675.
Cantina Managed
Verified.
s_globalPending updates break accounting invariants
State
- Fixed
PR #635
Severity
- Severity: High
Submitted by
Riley Holterhus
Description
s_globalPendingtracks the globalpendingStakingandpendingUnstakingamounts. These values increase when delegate/undelegate actions are initiated and decrease once those actions are finalized in later epochs.There is only one
s_globalPendingvariable, and_crankValidators()updatess_globalPendingas each validator is processed. This means that by the time a validator is reached, all validators processed earlier in the same crank have already incremented or decremented the pending values based on their own actions. As a result, the pending amounts are not a consistent epoch-level state that applies to all validators.This breaks the accounting and can cause the system to error and become stuck. For example, if earlier validators initiate withdrawals (increasing
pendingUnstaking), the global unstakeable amount returned bygetGlobalAmountAvailableToUnstake()is reduced for validators processed later. A later validator can then hit an underflow incalculateValidatorEpochStakeDelta(), since that function expects theglobalCashFlows_Last.queueForUnstaketo have been clamped to be less than the global unstakeable amount, which is not correctly maintained.Recommendation
Update the
s_globalPendingaccounting to provide a consistent epoch-level snapshot of the system.Fastlane
Cantina Managed
Verified fix. Note that future upgrades should be careful with timing. Since the proxy admin owner is a Gnosis Safe, and Safe transactions can be executed by anyone once signatures are ready, the upgrade could be executed at an unexpected time. With this change, an upgrade in the middle of validator cranking could lead to accounting errors, since some validators would use the updated accounting and others would not. However, accounting errors already exist in the current code regardless of the upgrade, so this is unavoidable.
agentWithdrawFromCommitted() burn is not adjusted when withdrawal is clamped
State
- Fixed
PR #624
Severity
- Severity: High
Submitted by
Riley Holterhus
Description
In
agentWithdrawFromCommitted(), whenamountSpecifiedInUnderlying == false, the following logic runs:uint256 _grossAssetsWanted = _convertToAssets(amount, OZMath.Rounding.Floor, true, false);uint256 _grossAssetsCapped;(_grossAssetsCapped, _feeTaken) = _getGrossCappedAndFeeFromGrossAssets(_grossAssetsWanted);_assetsToReceive = _grossAssetsCapped - _feeTaken;_sharesToDeduct = amount.toUint128();Here, the
amountis treated as a gross shares amount. This is converted into a gross asset amount via_convertToAssets(). That gross asset amount is then fed into_getGrossCappedAndFeeFromGrossAssets().The
_getGrossCappedAndFeeFromGrossAssets()function checks whether the net assets (after fees) would exceed the unstaking pool's available liquidity. If so, it clamps the gross asset amount so the net withdrawal fits within the pool's limits.If this clamping happens, notice that the
_assetsToReceivein the above code will be reduced, yet the_sharesToDeductis not adjusted to account for the fact that fewer assets are being withdrawn. This can lead to a very large loss of value if the_assetsToReceiveare heavily clamped and the originalamountwas a large amount of shares.In the worst case, an attacker could frontrun another user's call to
agentWithdrawFromCommitted()with their own unstaking actions to intentionally reduce liquidity in the unstaking pool, which would forceagentWithdrawFromCommitted()to realize a loss of assets relative to the shares burned.Recommendation
Consider proportionally adjusting
_sharesToDeductwhen_getGrossCappedAndFeeFromGrossAssets()clamps the withdrawal. Alternatively, consider reverting when the clamping occurs. This is how theamountSpecifiedInUnderlying == truecase works.Fastlane
Fixed in PR 624 by adding a revert in this scenario.
Cantina Managed
Verified.
Medium Risk8 findings
Incorrect underflow protection prevents slashing accounting adjustments
State
- Fixed
PR #628
Severity
- Severity: Medium
Submitted by
Christos Pap
Description
In the
_handleComplete_Allocation()function inStakeTracker, when handling the case where_actualActiveStake < _expectedActiveStake(indicating potential slashing), the code attempts to decrementvalidatorEpochPtrLast.targetStakeAmountands_globalCapital.stakedAmountby_deltawith underflow protection. However, the underflow protection logic is flawed and prevents the decrement from occurring.The code calculates
_delta128as follows:uint128 _delta128 = (_delta > _expectedTotalStake ? _delta - _expectedTotalStake : 0).toUint128(); validatorEpochPtrLast.targetStakeAmount -= _delta128;s_globalCapital.stakedAmount -= _delta128;The issue is that
_deltais calculated as_expectedActiveStake - _actualActiveStake, where_expectedActiveStake = _expectedTotalStake - _actualPendingDeposits. This means:_delta = _expectedActiveStake - _actualActiveStake = (_expectedTotalStake - _actualPendingDeposits) - _actualActiveStake ≤ _expectedTotalStakeSince
_deltacan never exceed_expectedTotalStake, the condition_delta > _expectedTotalStakewill always evaluate to false, resulting in_delta128always being set to 0. Consequently, the accounting adjustments tovalidatorEpochPtrLast.targetStakeAmountands_globalCapital.stakedAmountnever occur.However, this scenario may not occur under normal circumstances, as slashing is not yet implemented on Monad and the condition
_actualActiveStake < _expectedActiveStakeis not expected to happen in typical operation. If such conditions were to happen, the underflow protection logic would prevent the necessary accounting adjustments from being applied, leading to potential accounting issues.Recommendation
Consider fixing the underflow protection logic to properly handle the decrement. The intended behavior appears to be taking the minimum of
_deltaand the available stake amount. However, since_deltais already bounded by_expectedTotalStake, the underflow protection may be unnecessary for this specific case.If underflow protection is desired, one solution may be to cap the
_deltaagainstvalidatorEpochPtrLast.targetStakeAmountinstead:uint128 _delta128 = Math.min(_delta, uint256(validatorEpochPtrLast.targetStakeAmount)).toUint128(); validatorEpochPtrLast.targetStakeAmount -= _delta128;s_globalCapital.stakedAmount -= _delta128;Fastlane
Fixed in PR 628.
Cantina Managed
Verified.
_crankGlobal() uses stale unstakeable amount
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Riley Holterhus
Description
When a new Monad epoch begins,
_crankGlobal()typically runs before_crankValidators(). Entering a new epoch can change the effective global unstakeable amount, for example, prior deposits may now be active. However, this change is not reflected in the internal accounting until_crankValidators()updates the relevant state.As a result, any usage of
getGlobalAmountAvailableToUnstake()during the global crank is using stale data. This affects the_offsetExcessQueueCapacityWithNet()and_clampQueuesToCapacityOrRoll()functions, which use the unstakeable amount to adjustqueueToStakeandqueueForUnstake. Using outdated values here can affect the downstream actions during validator cranking.Recommendation
Consider updating the global unstakeable accounting to be up-to-date in the global crank. This could be accomplished if the global accounting knew how many pending deposits are now active in the new epoch.
Fastlane
We disagree with this finding, not because it's wrong but because we already do the proposed solution.
I believe that we want globalUnstakableAmount and the validatorUnstakableAmount to both be snapshotted to the same moment.
As discussed in finding "
s_globalPendingupdates break accounting invariants", we're (going to be) snapshotting thes_globalPending_Lastso that it doesn't update (and using that in stake allocation calculations) and then making it so thats_globalPendingcontinues to continuously update.In order for us to know how many pending deposits are now active in the new epoch, we'd need to call out to each of the validators. That's a potentially unbounded for loop of external calls, so instead we've just added that functionality to the validator crank.
Typically, after the global crank is run the validator crank will immediately be run next and it does exactly what you're describing, with the exception of it being separate transactions. Note that:
- The 'current' (not last, per fix of "
s_globalPendingupdates break accounting invariants") GlobalUnstakableAmount is used only for the calculation of when a user can complete unstaking - We don't want to put an unbounded for loop inside the global crank - we may run out of gas
- We don't want to break the global crank up into multiple transactions - that would allow the possibility of users doing deposits and withdrawals in the middle of the global crank stake updates which seems like a huge security risk
So we've made the global crank concise and we've made the validator-specific stuff not-time sensitive. A downside of this is that the withdrawal waiting period is longer than itd otherwise have to be, but we view this as a totally acceptable tradeoff given the alternatives.
Cantina Managed
Acknowledged.
Validator removal can strand rewardsPayable and earnedRevenue accounting
State
- Fixed
PR #646
Severity
- Severity: Medium
Submitted by
Riley Holterhus
Description
When
sendValidatorRewards()is called for avalidatorIdthat's currently registered in shMonad, the function sets aside rewards to be distributed asexternalRewards()for the validator in the next epoch.A problem can arise if this happens while the validator is pending removal (e.g. via
deactivateValidator()). If the global crank has already run but the validator has not yet been cranked, the subsequent validator crank might complete the removal. This means the rewards planned to be distributed in the next epoch will never be paid out, and will remain permanently as a reserved liability in the contract.There may be similar concerns with the
_handleBoostYield()and_handleBoostYieldFromShares()functions. Although these functions don't increment a validator'srewardsPayable, they do increment theearnedRevenue. As a result, in the next epoch if the validator is removed, the globalearnedRevenuemight no longer equal the sum of all validators' earned revenue.Recommendation
Consider preventing these edge cases. One option is to modify the functions so validators pending deactivation are treated as no longer belonging to shMonad.
Fastlane
Cantina Managed
Verified.
Delayed externalReward() call allows delegation frontrunning
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Riley Holterhus
Description
The
sendValidatorRewards()function sets aside rewards to be shared with the validator and its delegators in the next epoch. Because there is a delay before rewards are actually sent viaexternalReward(), a user could frontrun and delegate directly to the validator to gain access to the incoming rewards.Recommendation
Consider making the
externalReward()call immediately insendValidatorRewards().Fastlane
We do not perceive this to be an issue at this time. It is an unfortunate side effect of the Monad staking design. Full mitigations are impossible, and partial mitigations would only work for MEV earned during the boundary block - a very small percentage of the overall MEV. We do not believe a minor mitigation is worth the extra complexity.
Note also that if we could deposit the validator rewards when they're collected then we would, but MEV transactions are the most gas-sensitive of any transaction type, and the external reward function is quite gas intensive. External rewarding in each MEV transaction is a non-starter. Running a second crank would not fully solve the issue, either.
We're lobbying the monad team for the deposit wait period to be the same as the withdrawal wait period (1 epoch - 2 in boundary) rather than its shorter, current duration (0 - 1). From our POV, that is the only real solution.
Cantina Managed
Acknowledged.
Incorrect pendingStaking handling in getValidatorAmountAvailableToUnstake()
State
- Fixed
PR #672
Severity
- Severity: Medium
Submitted by
Riley Holterhus
Description
The
getValidatorAmountAvailableToUnstake()function is implemented as follows:function getValidatorAmountAvailableToUnstake( Epoch memory validatorEpoch_LastLast, Epoch memory validatorEpoch_Last, StakingEscrow memory validatorPendingEscrow_Last, StakingEscrow memory validatorPendingEscrow_LastLast) internal pure returns (uint256 amount){ amount = validatorEpoch_Last.targetStakeAmount; if (validatorEpoch_Last.hasDeposit) { uint256 _unavailable_Last = validatorPendingEscrow_Last.pendingStaking; amount = _saturatingSub(amount, _unavailable_Last); } if (validatorEpoch_LastLast.hasDeposit && validatorEpoch_LastLast.crankedInBoundaryPeriod) { uint256 _unavailable_LastLast = validatorPendingEscrow_LastLast.pendingStaking; amount = _saturatingSub(amount, _unavailable_LastLast); }}The purpose of this function is to subtract deposits from previous epochs that are still pending. The only deposits that can still be pending in a given Monad epoch are those made during the boundary period of the previous Monad epoch. The current implementation does not reflect this rule and subtracts pending staking from earlier internal epochs in cases where it should not.
A more accurate function would look like:
function getValidatorAmountAvailableToUnstake( Epoch memory validatorEpoch_Last, StakingEscrow memory validatorPendingEscrow_Last) internal pure returns (uint256 amount){ amount = validatorEpoch_Last.targetStakeAmount; if (validatorEpoch_Last.hasDeposit && validatorEpoch_Last.crankedInBoundaryPeriod) { uint256 _unavailable_Last = validatorPendingEscrow_Last.pendingStaking; amount = _saturatingSub(amount, _unavailable_Last); }}However note that this logic also becomes incorrect when
validatorEpoch_Lastcorresponds to a Monad epoch that occurred more than one Monad epoch ago. In such cases, the deposit is already finalized from Monad's perspective even though the internal epoch difference is only one.In practice, most calls to
getValidatorAmountAvailableToUnstake()happen after_settlePastEpochEdges()clears thehasDepositflag for finalized deposits, which reduces the likelihood of issues. However the function is incorrect in isolation and it is incorrect whenever the previous internal epoch is more than one Monad epoch ago.Recommendation
Consider removing the
validatorEpoch_LastLastandvalidatorPendingEscrow_LastLastlogic, and only subtract pending staking fromvalidatorPendingEscrow_Lastwhen the deposit was made in the boundary period of the Monad epoch directly before the current one.Fastlane
Fixed in PR 672.
Cantina Managed
Verified. Other changes made the
getValidatorAmountAvailableToUnstake()unused, so it has been removed._settlePastEpochEdges() can finalize actions late
State
- Fixed
PR #645
Severity
- Severity: Medium
Submitted by
Riley Holterhus
Description
The
_settlePastEpochEdges()function settles delegate/undelegate actions from previous epochs that may now be finalized. It does this by checking the previous three internal epochs forhasWithdrawalorhasDepositvalues. The implementation bases finalization solely on the number of internal epochs that have passed, even though multiple Monad epochs may have elapsed between two internal epochs.As a result, the logic does not always update the accounting when Monad first considers these actions finalized. For example, if the previous internal epoch actually corresponds to two Monad epochs ago, the deposit is already finalized from Monad's perspective, but
_settlePastEpochEdges()may not treat it as finalized yet.The main consequence is that accounting updates can occur later than they really should, which has downstream effects on things like stake allocation decisions. The current behavior does not appear to cause missed finalizations, since all deposits and withdrawals are eventually finalized after three internal epochs.
Recommendation
To keep the accounting up-to-date with the current Monad state, consider updating
_settlePastEpochEdges()to take into account the actual Monad epoch associated with the prior internal epochs when determining whether actions should be finalized.Fastlane
Addressed in PR 645.
Cantina Managed
Verified. Note that this is a partial fix. The new code makes settlement earlier in some scenarios where the previous shmonad epoch is older than one monad epoch. Settlement for epochs further back is unchanged.
Validators can be cranked twice within one Monad epoch
State
- Fixed
PR #637
Severity
- Severity: Medium
Submitted by
Riley Holterhus
Description
If a validator is cranked in a later Monad epoch than the global crank, then that validator crank can be immediately followed by the next global crank. Because the global crank resets the validator cursor, the validator becomes eligible to be cranked again within the same Monad epoch. This causes two internal validator epochs to correspond to a single Monad epoch. This breaks assumptions in parts of the accounting logic.
For example,
_settlePastEpochEdges()may treat a deposit from internal epoch –1 as finalized even though the deposit was just made in the same timestamp. This might cause the system to attempt to withdraw more stake than is actually available. While the implementation uses try/catch blocks to prevent the crank queue from getting stuck in these scenarios, this still has downstream accounting consequences.Another concern is withdrawal finalization. The
_settlePastEpochEdges()function only looks back three internal epochs when determining whether a withdrawal should be finalized. If internal epochs advance quickly enough relative to the amount of Monad epochs, it may be possible for four internal epochs to pass before a withdrawal becomes eligible from Monad's perspective. If this happens, the withdrawal would never be finalized or recorded, leading to an untracked withdrawal (which could also cause reverts once the withdrawal id is reached again). However it appears that this is not possible in the code, partially due to the fact that a global crank in an epoch's boundary period must wait one full epoch until the next global crank.Recommendation
Ensure that internal validator epochs cannot advance in a way that causes two of them to map to the same Monad epoch.
Fastlane
Fixed in PR 637.
Cantina Managed
Verified. After the change,
globalEpochPtr_N(0).epochgets synced when validator cranking runs after a Monad epoch boundary, so_crankGlobal()can't immediately run again in the same Monad epoch and advance the internal epoch twice.process() early return blocks commission payouts
State
- Fixed
PR #614
Severity
- Severity: Medium
Submitted by
Riley Holterhus
Description
In the Coinbase
process()function, the contract splits its current MON balance into a validator commission and a reward portion to send via the staking precompile'sexternalReward()function. BecauseexternalReward()reverts if called with a value less thanMIN_VALIDATOR_DEPOSIT(1 MON), the code adds an early return when_rewardPortion < MIN_VALIDATOR_DEPOSIT:function process() external onlyShMonad returns (bool success) { // ... if (_rewardPortion < MIN_VALIDATOR_DEPOSIT) return false; // ... try this.sendCommissionAndRewards(_config.commissionRecipient, _validatorCommission, _rewardPortion) { success = true; } catch { success = false; }}This early return can stop
process()prematurely even when it should still pay out the commission. For example if the validator's commission rate is 100%, then_rewardPortionwill always be zero, soprocess()will always hit the early return and never callsendCommissionAndRewards(). This means the validator will never be sent the commission.This issue was raised by the shMonad team during the audit.
Recommendation
Refactor the early return logic so that it doesn't unnecessarily block the validator commission payout.
Fastlane
Fixed in a refactor that created a new Coinbase contract. This included PR 614 and PR 665.
Cantina Managed
Verified.
Low Risk8 findings
ECDSA.recover may prevent ERC-1271 fallback path for contract accounts
State
- Fixed
PR #623
Severity
- Severity: Low
Submitted by
Christos Pap
Description
The
permit()function inFLERC20.solusesECDSA.recover()which reverts on malformed signatures, preventing the ERC-1271 fallback path from being checked for contract accounts. This may cause valid permit operations from smart contract wallets (including EIP-7702 accounts) to fail when the ECDSA signature format is invalid, even if the contract account has a valid ERC-1271 signature.In the
permit()function atFLERC20.sol, the code attempts to recover the signer address usingECDSA.recover():function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) public virtual{ require(block.timestamp <= deadline, ERC2612ExpiredSignature(deadline)); bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)); bytes32 hash = _hashTypedDataV4(structHash); // 1) Try ECDSA first (works for EOAs, including 7702 EOAs with transient code) address recovered = ECDSA.recover(hash, v, r, s); if (recovered != owner) { // 2) If not an ECDSA signer, try ERC-1271 for contract accounts if (owner.code.length == 0) revert ERC2612InvalidSigner(recovered, owner); bytes memory sig = abi.encodePacked(r, s, v); bool ok = SignatureChecker.isValidSignatureNow(owner, hash, sig); require(ok, ERC2612InvalidSigner(address(0), owner)); } _approve(owner, spender, value);}The issue is that
ECDSA.recover()reverts when the signature is malformed (e.g., invalidv,r, orsvalues). When this happens, the entire transaction reverts before reaching the ERC-1271 fallback check. This may prevent contract accounts from using ERC-1271 validation when their signatures are not valid ECDSA signatures.OpenZeppelin's
SignatureChecker.isValidSignatureNow()function addresses this by usingECDSA.tryRecover()instead ofECDSA.recover(), which returns an error code rather than reverting, allowing the code to proceed to ERC-1271 validation when ECDSA recovery fails.Recommendation
It is recommended to replace
ECDSA.recover()withECDSA.tryRecover()to handle malformed signatures gracefully and allow the ERC-1271 fallback path to execute:// 1) Try ECDSA first (works for EOAs, including 7702 EOAs with transient code)(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s);if (err != ECDSA.RecoverError.NoError || recovered != owner) { // 2) If not an ECDSA signer, try ERC-1271 for contract accounts if (owner.code.length == 0) revert ERC2612InvalidSigner(recovered, owner); bytes memory sig = abi.encodePacked(r, s, v); bool ok = SignatureChecker.isValidSignatureNow(owner, hash, sig); require(ok, ERC2612InvalidSigner(address(0), owner));}Alternatively, consider using
SignatureChecker.isValidSignatureNow()directly for both EOA and contract account cases, as it handles both scenarios internally and usestryRecover()appropriately.However, note that this approach checks for code length first, which may not align with the current implementation's logic that attempts ECDSA recovery first.
Fastlane
Fixed in PR 623.
Cantina Managed
Verified.
Unexpected error event emitted on successful withdrawal
State
- Fixed
PR #620
Severity
- Severity: Low
Submitted by
Christos Pap
Description
In the
_settleCompletedStakeAllocationDecrease()function inStakeTracker, theUnexpectedStakeSettlementErrorevent is emitted when a withdrawal operation succeeds. The event name suggests an error condition, but it is triggered in the success path where_successistrueand_handleCompleteDecreasedAllocation()is called normally.} else if (_success) { _handleCompleteDecreasedAllocation( valId, validatorEpochPtr, validatorPendingPtr, _amountReceived.toUint120() ); emit UnexpectedStakeSettlementError(coinbase, valId, _amountReceived, 1);The same event is also correctly emitted in the failure path when
_successisfalse, making it ambiguous whether the event indicates success or failure without examining theactionIndexparameter.Recommendation
Consider removing the event emission from the success path, or renaming the event to better reflect its purpose.
Fastlane
Fixed in pull/620. Replaced incorrect UnexpectedStakeSettlementError emission in the success path of _settleCompletedStakeAllocationDecrease() with ValidatorUnstakeCompleted event.
Cantina Managed
Verified.
Block timing inconsistency between uncommittingCompleteBlock view and completion check
State
- Fixed
PR #622
Severity
- Severity: Low
Submitted by
Christos Pap
Description
There is a timing inconsistency between the
uncommittingCompleteBlock()view function and the actual completion check in_completeUncommitFromPolicy(). The view function returnsuncommitStartBlock + escrowDurationas the completion block, indicating that uncommitting can be completed at that block.However, the
_completeUncommitFromPolicy()function uses a strict greater than comparison (block.number > uncommittingData.uncommitStartBlock + policyEscrowDuration), requiring an additional block before completion is allowed.function uncommittingCompleteBlock(uint64 policyID, address account) external view returns (uint256) { return s_uncommittingData[policyID][account].uncommitStartBlock + s_policies[policyID].escrowDuration;}require( block.number > uncommittingData.uncommitStartBlock + policyEscrowDuration, UncommittingPeriodIncomplete(uncommittingData.uncommitStartBlock + policyEscrowDuration));When
block.numberequalsuncommitStartBlock + escrowDuration, users queryinguncommittingCompleteBlock()will receive a value indicating completion is ready, but calls tocompleteUncommit(),completeUncommitWithApproval(), or other completion functions will revert until the next block.This creates a one block delay between when the view function indicates readiness and when completion is actually possible.
Recommendation
Consider aligning the timing logic by either:
- Changing the condition in
_completeUncommitFromPolicy()from>to>=, allowing completion atuncommitStartBlock + escrowDurationas the view function suggests:
require(- block.number > uncommittingData.uncommitStartBlock + policyEscrowDuration,+ block.number >= uncommittingData.uncommitStartBlock + policyEscrowDuration, UncommittingPeriodIncomplete(uncommittingData.uncommitStartBlock + policyEscrowDuration));- Alternatively, updating the
uncommittingCompleteBlock()view function to returnuncommitStartBlock + escrowDuration + 1to match the actual completion requirement, and updating any related documentation accordingly.
Fastlane
Fixed in pull/622. Changed comparison from > to >= so users can complete uncommit at the exact block returned by the view function
Cantina Managed
Verified.
ERC4626 standard compliance issues
State
- Fixed
PR #636
Severity
- Severity: Low
Submitted by
Christos Pap
Description
The implementation deviates from the ERC4626 standard. Two ways have been found, but as this is a custom implementation that has some differences from ERC4626, more may be present:
-
The
deposit()function inFLERC4626is declared aspublic payable, but the ERC4626 standard specifies thatdeposit()should beexternalandnon-payable. The implementation relies onmsg.valueto receive native MON tokens, as seen in the_deposit()function which checksrequire(assets == msg.value, IncorrectNativeTokenAmountSent()). When integrators interact with the contract through the standard ERC4626 interface usingIERC4626(shmonad).deposit(...), the call may revert if native tokens are not sent, or the function may not behave as expected if integrators assume the standard non-payable signature. -
The
previewRedeem()function may revert with extremely large share values when the contract has accumulated significant equity (for example, afterboostYield()calls). The function internally calls_convertToAssets(), which performs a multiplication operationshares.mulDiv(_equity + 1, _realTotalSupply() + 10 ** _decimalsOffset(), rounding). With very large share values neartype(uint256).maxand large equity values, this multiplication can overflow, causing the preview function to revert. Per ERC4626 specification, preview functions MUST NOT revert for any input value, as they are intended to be used for off-chain calculations and user interface displays.
function deposit( uint256 assets, address receiver) public payable virtual notWhenClosed nonReentrant returns (uint256)function previewRedeem(uint256 shares) public view virtual returns (uint256) { (uint256 netAssets,) = _previewRedeem(shares); return netAssets;} function _convertToAssets( uint256 shares, Math.Rounding rounding, bool deductRecentRevenue, bool deductMsgValue) internal view virtual returns (uint256){ uint256 _equity = deductMsgValue ? _totalEquity({ deductRecentRevenue: deductRecentRevenue }) - msg.value : _totalEquity({ deductRecentRevenue: deductRecentRevenue }); return shares.mulDiv(_equity + 1, _realTotalSupply() + 10 ** _decimalsOffset(), rounding);}Recommendation
Consider the following approaches to improve ERC4626 compliance:
-
For the
deposit()function signature issue, consider documenting the deviation from the standard and providing clear integration guidance. -
For the
previewRedeem()function, document that, as per the current design, this may overflow in some extreme cases.
Fastlane
Added documentation in PR 636.
Cantina Managed
Verified.
Division by zero when totalEquity is zero can block the _crankGlobal() execution
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Christos Pap
Description
In the
_checkSetNewAtomicLiquidityTarget()function,_totalEquityis calculated from the global capital state and used as the denominator in percentage calculations via_scaledPercentFromAmounts(). When all users request unstake of their equity valued shares,redemptionsPayablemay reduce_totalEquityto exactly zero.The
_scaledPercentFromAmounts()function performs division without checking if the denominator is zero:function _scaledPercentFromAmounts( uint256 unscaledNumeratorAmount, uint256 unscaledDenominatorAmount ) internal pure returns (uint256) { return unscaledNumeratorAmount * SCALE / unscaledDenominatorAmount; }When
s_pendingTargetAtomicLiquidityPercentequalsFLOAT_PLACEHOLDER(which is1) and_totalEquityis zero, the function enters the rebalancing logic and calls_scaledPercentFromAmounts(oldAllocatedAmount, _totalEquity), causing a revert as a division by zero occurs. This also occurs other code paths when_totalEquityis zero.Since
_checkSetNewAtomicLiquidityTarget()is called from_settleGlobalNetMONAgainstAtomicUnstaking(), which is invoked during_crankGlobal(), the revert blocks epoch advancement and protocol operations.This issue is reachable after the contracts are initially deployed, but it becomes harder to trigger once the system is live. This is because the revenue streaming logic will naturally leave residual equity behind during withdrawals. Also note that this issue could be resolved by someone transferring MON directly into the contract.
Proof of Concept
The following test demonstrates the issue:
function test_total_equity_zero() public { _advanceEpochAndCrank(); address[4] memory depositors = [deployer, solverOneEOA, solverTwoEOA, solverThreeEOA]; for (uint256 i; i < 4; ++i) { vm.startPrank(depositors[i]); shMonad.requestUnstake(shMonad.balanceOf(depositors[i])); vm.stopPrank(); } // After all users request unstakes, totalEquity becomes 0 // Next crank attempt will revert with division by zero _advanceEpochAndCrank(); // Reverts: panic: division or modulo by zero (0x12)}Recommendation
Consider adding zero checks for
_totalEquitybefore performing division operations, while preserving admin configuration and pending targets. When_totalEquityis zero, the function may short circuit before any percentage math and treat the allocation as zero for that epoch:function _checkSetNewAtomicLiquidityTarget( uint128 oldUtilizedAmount, uint128 oldAllocatedAmount) internal returns (uint256 scaledTargetPercent, uint128 newAllocatedAmount){ // Load any pending atomic liquidity percentage uint256 _newScaledTargetPercent = s_pendingTargetAtomicLiquidityPercent; // Load relevant values WorkingCapital memory _globalCapital = s_globalCapital; uint256 _totalEquity = _globalCapital.totalEquity(s_globalLiabilities, s_admin, address(this).balance); uint256 _currentAssets = _globalCapital.currentAssets(s_atomicAssets, address(this).balance); // Handle zero equity case: avoid division by zero but do not mutate admin config. if (_totalEquity == 0) { // Use the current or pending target percent only as an informational return value. uint256 effectiveScaled = _newScaledTargetPercent == FLOAT_PLACEHOLDER ? _scaledTargetLiquidityPercentage() : _newScaledTargetPercent; // With zero equity, the atomic pool allocation must also be zero. return (effectiveScaled, 0); } // ... rest of function logic ...}Fastlane
Acknowledged.
Cantina Managed
Acknowledged.
Placeholder validator wasCranked flag never set
State
- Fixed
PR #626
Severity
- Severity: Low
Submitted by
Riley Holterhus
Description
The placeholder validator uses the global epoch storage to track its state. Its
wasCrankedfield is never set totrue, even though_crankPlaceholderValidator()reads this value to verify it isfalse.Recommendation
Ensure the placeholder validator has its
wasCrankedvalue set totrueafter it's cranked.Fastlane
Fixed in PR 626.
Cantina Managed
Verified.
Crank timing mismatch within an internal epoch
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Riley Holterhus
Description
Validators within the same internal epoch can be cranked at significantly different times, potentially separated by multiple Monad epochs. This timing gap may lead to unexpected accounting outcomes.
For example, if the system goes down for a period of time, a validator may not be cranked until hours after the previous validator. By that time, the later validator will have accrued substantially more rewards when
_claimRewards()runs. During the next crank, this validator will appear to have higherearnedRevenueand will receive a larger share of new stake purely due to the timing difference, not due to their performance.Recommendation
Consider documenting this behavior.
Fastlane
We disagree with this finding, because the stake allocation is based on an average earnedRevenue per validator, across the last 2 epochs. So if they crank early to move earnings into the later epoch, its just reducing earnings from the prev epoch.
Cantina Managed
Agreed if a validator has a longer reward period in epoch
ethen it means they'll have a shorter reward period in epoche+1, and this would be averaged out in epoche+2But in epoch
e+1it'd be using the average ofeande-1, and in epoche+3it'd be using the average ofe+2ande+1, so there would still be epochs where a validator gets a higher/lower % of the new stake due to timing. And since the queueToStake changes across epochs, the overall outcome is different compared to if all validators were cranked at the same timeFastlane
I think the important thing here is that cranking is permissionless and the cost is very very cheap due to the chain. If any validators feel that they'd be disadvantaged by an epoch extending they can just do the crank themselves. Similarly, the validators that are already getting a lot of stake--the ones who'd benefit from this due to having a higher revenue--are incentivized to do the crank as soon as possible so that their new stake can be processed and start earning. In essence, the cost of the delay applies to all validators and the benefit is probabalistic at best.
Cantina Managed
Acknowledged.
EIP-7702 allows Coinbase EOA to turn into arbitrary code
State
- Fixed
PR #633
Severity
- Severity: Low
Submitted by
Riley Holterhus
Description
When a validator is cranked, the final step is to call
process()on the validator's configured coinbase address, but only if that address currently has code:address coinbase = _validatorCoinbase(_valId);if (coinbase.code.length > 0) { try ICoinbase(coinbase).process{ gas: COINBASE_PROCESS_GAS_LIMIT }() { } catch { }}The idea of this system is that the shMonad admin configures each validator's coinbase as either their EOA or as the standard
Coinbasecontract implementation in the codebase.However note that with EIP-7702, an EOA can essentially change into a contract. As a result, a validator with an EOA coinbase can setup arbitrary code to be executed during the crank. This expands the validator's power in a way that may be unexpected.
This issue was raised by the shMonad team during the audit.
Recommendation
Consider preventing this behavior by disallowing EOAs outright, or by checking that a coinbase address having code isn't due to an EIP-7702 delegation.
Fastlane
Fixed in PR 633 by skipping coinbase addresses that have code that begin with the EIP-7702 prefix.
Cantina Managed
Verified.
Informational17 findings
Missing NatSpec documentation, typos and other code improvement issues may affect the readability of the contracts
State
- Fixed
PR #617
Severity
- Severity: Informational
Submitted by
Christos Pap
Description
Some code quality issues were identified across multiple contracts that affect documentation, code clarity, and maintainability:
-
Missing NatSpec documentation: The
requestUnstake()function inShMonad.sollacks NatSpec documentation. This public function is part of the core unstaking functionality and should include parameter descriptions and return value documentation. -
Comment typo: In
StakeTracker.solat line 270, a comment contains a typo: "aount" should be "amount". -
Unnecessary variable declaration: In
FLERC4626.sol, the_previewDeposit()and_previewMint()functions declare a local variable_deductRecentRevenueset tofalsethat is only used once as a function argument. This variable can be removed andfalsecan be passed directly to_convertToShares()and_convertToAssets().
Recommendation
It is recommended to implement the above changes in order to improve the readability and the maintainability of the contracts.
Fastlane
Fixed in PR 617.
Cantina Managed
Verified.
Missing event emission for staking commission zero-yield balance update
State
- Fixed
PR #618
Severity
- Severity: Informational
Submitted by
Christos Pap
Description
In
StakeTracker.sol, the_handleEarnedStakingYield()function tracks staking commission by directly updating the owner's zero-yield balance without emitting the corresponding event. At line 1650, when_grossStakingCommissionis added tos_zeroYieldBalances[OWNER_COMMISSION_ACCOUNT], noDepositToZeroYieldTrancheevent is emitted.s_zeroYieldBalances[OWNER_COMMISSION_ACCOUNT] += _grossStakingCommission;This pattern is inconsistent with the current approach used in
_depositToZeroYieldTranche()inShMonad.sol, which emitsDepositToZeroYieldTranchewhenever zero-yield balances are updated:function _depositToZeroYieldTranche(uint256 assets, address from, address to) internal { AdminValues memory _admin = s_admin; require(msg.value == assets, IncorrectNativeTokenAmountSent()); // Increase zero-yield tranche balance s_zeroYieldBalances[to] += assets; // Increase total liabilities to reflect the new assets held in the zero-yield tranche _admin.totalZeroYieldPayable += uint128(assets); // Queue up the new funds to be staked _accountForDeposit(assets.toUint128()); // Persist AdminValues changes to storage s_admin = _admin; emit DepositToZeroYieldTranche(from, to, assets); }Recommendation
Consider emitting the
DepositToZeroYieldTrancheevent when updating the owner commission zero-yield balance in_handleEarnedStakingYield()as this would maintain consistency with the established pattern.Fastlane
Fixed in pull/618. Now we emit DepositToZeroYieldTranche event in _handleEarnedStakingYield() when staking commission is credited to the owner's zero-yield balance
Cantina Managed
Verified.
Mint function requires previewMint call to determine correct msg.value
State
- Fixed
PR #621
Severity
- Severity: Informational
Submitted by
Christos Pap
Description
The
mint()function inFLERC4626accepts asharesparameter as input, but requires thatmsg.valueexactly matches the calculated asset amount. The function internally calls_previewMint()to determine the required assets based on the current exchange rate, then passes this value to_deposit(), which enforcesassets == msg.value.function mint(uint256 shares, address receiver) public payable virtual returns (uint256) { uint256 maxShares = maxMint(receiver); require(shares <= maxShares, ERC4626ExceededMaxMint(receiver, shares, maxShares)); uint256 assets = _previewMint(shares, true); _deposit(_msgSender(), receiver, assets, shares); return assets;}The
_deposit()function validates that the sent native token amount matches the calculated assets:function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual { require(assets == msg.value, IncorrectNativeTokenAmountSent()); // ...}Because the exchange rate used by
_previewMint()depends on the current on chain state (including total equity and supply), EOAs and users must callpreviewMint()off chain first to determine the exactmsg.valuerequired before executingmint(). This creates a two step process that may not be immediately obvious to integrators or end users.Recommendation
Consider adding documentation or NatSpec comments to
mint()that explicitly state users should callpreviewMint()first to determine the requiredmsg.value.Fastlane
Added NatSpec documentation in
pull/621to mint() function in FLERC4626.sol explaining that callers must call previewMint() off-chain first to determine the exact msg.value required.Cantina Managed
Verified.
Staking precompile does not trigger receive() as expected
State
- Fixed
PR #651
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The
_claimRewards()and_completeWithdrawal()functions use theexpectsStakingRewardsandexpectsUnstakingSettlementmodifiers. These modifiers set thet_cashFlowClassifiertransient storage variables so that any incoming MON sent to the contract throughreceive()can be classified properly.However, the Monad staking precompile does not transfer MON via an EVM call that would trigger
receive(). Instead it updates the contract's balance at the protocol level without transferring control-flow. Because noreceive()call occurs, these modifiers appear to have no effect and can be removed.Recommendation
Consider removing the
expectsStakingRewardsandexpectsUnstakingSettlementmodifiers, and potentially remove the transient capital system altogether.Fastlane
Fixed in PR 651.
Cantina Managed
Verified.
Incorrect index in __StakeTracker_init()
State
- Fixed
PR #629
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The
__StakeTracker_init()function contains the following assignments:globalEpochPtr_N(-2).epoch = _currentEpoch < 3 ? 0 : _currentEpoch - 3;globalEpochPtr_N(-2).epoch = _currentEpoch < 2 ? 0 : _currentEpoch - 2;globalEpochPtr_N(-1).epoch = _currentEpoch < 1 ? 0 : _currentEpoch - 1;globalEpochPtr_N(0).epoch = _currentEpoch;globalEpochPtr_N(1).epoch = _currentEpoch + 1;globalEpochPtr_N(2).epoch = _currentEpoch + 2;The first assignment appears intended for index -3, but it is written to index -2 instead. This does not appear to have any notable effect in the rest of the code.
Recommendation
Change the first assignment to use index -3 instead of -2.
Fastlane
Fixed in PR 629.
Cantina Managed
Verified. Also note that this code will likely never be used again.
Storage clearing happens earlier than expected
State
- Fixed
PR #640
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The
_crankGlobal()function clears theglobalRevenuePtr_NandglobalCashFlowsPtr_Nstorage at index offset +2. This is unexpected because the next epoch slot that is actually about to be used is index offset +1. This does not cause issues in practice, since the storage is still cleared ahead of time, just earlier than necessary.A similar pattern appears in
_rollValidatorEpochForwards(), which clearsvalidatorRewardsPtr_NandvalidatorPendingPtr_Nat index offset +2 rather than +1.Recommendation
Consider documenting this behavior or updating the code to clear index offset +1 instead.
Fastlane
Documented in PR 640.
Cantina Managed
Verified.
Validator can set commissionRecipient to an address that reverts
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The validator associated with a given Coinbase contract can update the
commissionRecipientto any arbitrary address. This allows the validator to set the recipient to an address that will revert whensendCommissionAndRewards()attempts to transfer MON.This does not appear to change the trust assumptions of the system. The
sendCommissionAndRewards()call is wrapped in a try/catch, and the validator already has the ability to direct all MON to themselves by setting thecommissionRateto 100% anyway.Recommendation
Consider documenting this behavior for clarity.
Fastlane
Acknowledged.
Cantina Managed
Acknowledged.
Withdrawals accrue rewards not expected by the StakeTracker
State
- Fixed
PR #641
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
When a delegator undelegates from a Monad validator, the withdrawal amount independently accrues rewards for a period of time during the withdrawal process. This additional yield is not expected in the StakeTracker logic. As a result,
_handleCompleteDecreasedAllocation()will typically observe a surplus. This does not appear to cause issues, as the code treats the surplus as additional yield.Recommendation
Consider documenting this behavior.
Fastlane
Documented in PR 641.
Cantina Managed
Verified.
Stake allocation starts very slow for new validators
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The shMonad system allocates new stake to validators based on their
earnedRevenueover the past two internal epochs relative to the globalearnedRevenue. A validator'searnedRevenueincreases either through staking yield or rewards (e.g. MEV) sent viasendValidatorRewards()and the boost yield functions.A newly added validator starts with no stake and therefore typically has little or no
earnedRevenue. This can slow its ability to gain new deposits. These validators may have to rely onsendValidatorRewards()and boost yield rewards to eventually accumulate enoughearnedRevenueto begin receiving stake.Recommendation
Consider whether this behavior could cause problems. If not, consider documenting this behavior. If so, consider adding mechanisms for newly added validators to receive stake.
Fastlane
Acknowledged.
Cantina Managed
Acknowledged.
Duplicate clearing logic in validator deactivation
State
- Fixed
PR #617
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The
_completeDeactivatingValidator()function contains the following logic:// Setting this enables future reactivationvalidatorEpochPtr_N(0, validatorId).epoch = 0; delete s_validatorIsActive[validatorId];_unlinkValidatorCoinbase(validatorId);delete s_validatorData[validatorId]; // Remove this validator from the link and connect together the validators on either end_removeValidatorFromCrankSequence(validatorId); for (uint256 i; i < EPOCHS_TRACKED; i++) { delete s_validatorEpoch[validatorId][i]; delete s_validatorRewards[validatorId][i]; delete s_validatorPending[validatorId][i];}The assignment
validatorEpochPtr_N(0, validatorId).epoch = 0is redundant because the loop that follows clears all epoch-related storage.Recommendation
Consider simplifying the function by removing the redundant storage assignment.
Fastlane
Fixed in PR 617.
Cantina Managed
Verified.
Storage variables are hard to reason about
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The codebase uses many storage variables and mappings, and it can be hard to understand what each one is responsible for.
For example,
s_validatorData[validatorId].isActiveands_validatorIsActive[validatorId]sound very similar but behave differently. During pending deactivation, onlys_validatorData[validatorId].isActiveis set to false whiles_validatorIsActive[validatorId]stays true. A single state variable that represents active, pending, inactive states would be easier to reason about.Another example is the reuse of the
Epochstruct. It's used for validator epoch data (s_validatorEpoch) as well as global epoch data (s_globalEpoch). Some fields don't apply to validators, like thefrozenorclosedvalues. Also,s_globalEpochis used to store placeholder validator fields such aswasCranked. Reusing one struct for several purposes makes it difficult to know which fields matter in which context.Recommendation
Consider simplifying and separating storage structs in a future version of the code.
Fastlane
Acknowledged.
Cantina Managed
Acknowledged.
calculateDeactivatedValidatorEpochStakeDelta() is unused
State
- Fixed
PR #631
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The
calculateDeactivatedValidatorEpochStakeDelta()is defined inStakeAllocationLibbut it is not used.Recommendation
Consider removing the
calculateDeactivatedValidatorEpochStakeDelta()function.Fastlane
Removed in PR 631.
Cantina Managed
Verified.
Missing reentrancy guards on external MONAD transfer functions
State
- Fixed
PR #638
Severity
- Severity: Informational
Submitted by
Christos Pap
Description
Two functions perform external ETH transfers without reentrancy guards, which may create opportunities for reentrancy attacks if the codebase evolves or gas limits are modified.
In the
process()function inCoinbase, thesendCommissionAndRewards()function callstrySafeTransferETH()to send validator commission to a recipient. This function is invoked during_crankValidator()inStakeTracker, which does not have reentrancy protection. While the external call is limited byTRANSFER_GAS_LIMIT(100,000) andCOINBASE_PROCESS_GAS_LIMIT(500,000), making reentrancy unlikely in the current implementation, the lack of explicit guards creates a potential risk if these gas limits may be increased in the future.In the
completeUnstake()function inShMonad, the function performs asafeTransferETH()call to send unstaked funds to the user account. The transfer occurs after state updates (deleting the unstake request and calling_beforeCompleteUnstake()), which mitigates immediate reentrancy concerns.While neither function appears exploitable in the current implementation, adding reentrancy guards would provide protection against future code changes.
Recommendation
Consider adding
nonReentrantmodifiers to both functions to provide explicit reentrancy protection.Fastlane
Cantina Managed
Verified.
Validator epoch roll copies unused fields
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The
_rollValidatorEpochForwards()function sets the next validator epoch storage at offset +1 and copies thefrozenandclosedfields from the current validator epoch into it.However, these fields are not used for validator-level storage and will always remain
false, so copying them has no effect.Recommendation
Consider removing the logic that copies the
frozenandclosedfields, and consider simplifying the storage structs.Fastlane
Acknowledged. We are reusing the placeholder Validator to track the global epoch. This inefficiency is by design.
Cantina Managed
Acknowledged.
Inaccurate comment above _settleEarnedStakingYield()
State
- Fixed
PR #625
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
Above the logic in the validator crank that calls
_settleEarnedStakingYield(), the code includes the following comment:// Pull validator rewards (net of commission) so rebalancing reflects latest earnings._settleEarnedStakingYield(_valId);This comment may be misleading. The rewards pulled in by
_settleEarnedStakingYield()update the validator'searnedRevenueat index offset 0, but stake allocation is based onearnedRevenueat offsets -1 and -2. Therefore, this function should not affect the upcoming rebalancing.Recommendation
Consider updating this comment.
Fastlane
Fixed in pull/625.
Cantina Managed
Verified.
Unattributed MON is intentionally excluded from global earnedRevenue
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
There are some locations in the code (e.g.
_handleValidatorRewards(),_handleBoostYield()) where incoming MON is intentionally not added to the globalearnedRevenuebecause the revenue cannot be attributed to a validator within shMonad. This decision was made because adding revenue globally without assigning it to a validator would dilute the stake distribution in the next crank.Note that one side effect of this behavior is that the incoming MON will not go through the revenue smoothing logic, and so it will increase the shMON exchange rate immediately.
Recommendation
No changes appear necessary for this behavior. Keep this behavior in mind.
Fastlane
Acknowledged.
Cantina Managed
Acknowledged.
Initialization pattern is difficult to reason about
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Riley Holterhus
Description
The current
initialize()function is annotated withreinitializer(10). This is because the contract has already gone through deployment and multiple upgrades, and the sameinitialize()function has been reused. This can make it hard to reason about the upgrade history, since theinitialize()function has changed over time.A simpler pattern would be to introduce a new function for each upgrade, for example
initialize()for the original deployment,initializeV2()for the first upgrade, etc., each with its owninitializerorreinitializerguard. This makes it easier to audit and to replay initializations in order in a fresh deployment, since each function represents a specific upgrade step.Recommendation
Consider simplifying the initialize logic in future versions of the code.
Fastlane
Acknowledged.
Cantina Managed
Acknowledged.