Skip to content
$1,000,000 in security audit grants are live now, Apply here →

Security review · December 2025

FastLane Protocol

for FastLane Labs

FastLane engaged Guardian to review the security of their FastLane Protocol. From the 27th of October to the 12th of November, a team of 4 auditors reviewed the source code in scope.

Published
Review window
October 27 to November 12, 2025
Rounds
Main Review, Discussion, Remediation Review
Language
Solidity
Chains
Monad
Sector
Staking, Infrastructure
  • 0 Critical
  • 4 High
  • 10 Medium
  • 7 Low
  • 19 Informational

23 resolved · 1 partially resolved · 16 acknowledged

Scope

Overview

FastLane engaged Guardian to review the security of their FastLane Protocol. From the 27th of October to the 12th of November, a team of 4 auditors reviewed the source code in scope.

Note: Fixes to the findings uncovered in the remediation review have not been reviewed by Guardian.

Security Recommendation Guardian recommends a follow-up security review of the protocol at a finalized frozen commit. The Fastlane team has acknowledged this and followed our recommendation with a secondary review.

Findings 40

Main Review

19 findings
  1. H-01 High Atomic Liquidity Fee Bypassed At Max Capacity Logical Error Resolved
    Location
    /src/shmonad/ShMonad.sol: 196
    Round
    Main Review

    Description

    Proof of concept: PoC

    The function agentWithdrawFromCommitted is meant to let a policy agent to pull from a user's committed balance while paying a fee to the atomic pool.

    This function has an isUnderlying argument that allows the caller to request the withdrawal to be denominated in MON.

    When the isUnderlying argument is set to false it first converts the share amount to gross assets, both gross and fee, the code then explicitly sets _assetsToReceive = gross - fee.|

    However, when the isUnderlying argument is set to true the function is meant to take the specified amount as net and calls _getGrossAndFeeFromNetAssets returning both gross and fee, however, when there is not enough liquidity in the pool, the function will cap the returned gross amount while maintaining the original net amount as the amount to transfer.

    This not only leads to the agent with being able to bypass the liquidity cap but also to seize the fee meant to buffer the float and capture protocol revenue, but also leaves the pool insolvent relative to its accounting, as the function calls in both cases function _accountForWithdraw(uint256 netAmount, uint256 fee).

    Which expects the netAmount to be passed along with the fee, whereas in this case, the gross amount is being sent along with a fee that is never captured producing a mismatch between the protocol accounting and the real protocol state.

    Moreover an agent can repeat the operation whenever the pool refills, harvesting the entire liquidity each time while paying zero utilization fee and leaving other users without the ability to instant withdrawal.

    Recommendation

    After calling _getGrossAndFeeFromNetAssets, calculate the deliverable net by calculating gross - fee and if it doesn't match the requested net amount either revert or return the actual net amount.

    Resolution

    FastLane Team: The issue was resolved in PR#532.

  2. H-02 High Withdrawals Exceed Intended Limits Logical Error Resolved
    Location
    StakeTracker.sol: 1486
    Round
    Main Review

    Description

    Proof of concept: PoC

    The _accountForWithdraw function manages accounting for instant withdrawals from the atomic unstaking pool. It allows withdrawals up to s_atomicAssets.allocatedAmount plus globalRewardsPtr_N(0).earnedRevenue.

    The issue arises when allocatedAmount is depleted and a shortfall is covered using earnedRevenue. In this case, the function increases allocatedAmount by the shortfall amount.

    As a result, subsequent checks compare _distributedAmount + netAmount against an inflated _allocatedAmount + _earnedRevenueOffset, effectively expanding the withdrawal limit.

    A user can repeatedly perform instant withdrawals as long as each withdrawal remains below earned revenue, gradually draining the contract and consuming funds reserved for other protocol operations.

    Recommendation

    Modify _accountForWithdraw to properly account for already used earnedRevenue, ensuring instant withdrawals cannot exceed the combined allocated and earnedRevenue amount.

    Resolution

    FastLane Team: The issue was resolved in PR#587.

  3. H-03 High Overflow DOS In Crank Locks Funds Logical Error Resolved
    Location
    StakeTracker.sol
    Round
    Main Review

    Description

    In StakeTracker._crankGlobal(), in the _checkSetNewAtomicLiquidityTarget call, there is the possibility of an overflow, in a specific scenario:

    • There has been an atomic unstake
    • A large amount of shMON has been requestUnstaked

    This causes equity to be very small, because most funds are offset by a liability to represent the unstake request.

    Then, when we get to the global crank phase after that setup, in _checkSetNewAtomicLiquidityTarget we calculate _newScaledTargetPercent as s_atomicAssets.distributedAmount / _totalEquity . This figure can be reach into the thousands of percents, and has in live testnet deployments.

    The overflow and revert then occurs when we call _unscaledTargetLiquidityPercentage() and pass in this value when it is larger than 655% (the max a uint16 can hold, when represented in basis points)

    Thus, the crank cannot complete, and therefore the users who requestUnstaked can never reach the epoch where they can call completeUnstake, so funds are stuck.

    Recommendation

    Cap the calculated _newScaledTargetPercent at 100%, and do an “emergency” decrease of allocated and distributed amounts in _afterRequestUnstake if the unstaked amount + allocated + recent revenue is > total equity.

    Resolution

    FastLane Team: The issue was resolved in PR#557.

  4. M-01 Medium Auto Top-up Ignores minCommitted Threshold Unexpected Behavior Resolved
    Location
    src/shmonad/Policies.sol: 660
    Round
    Main Review

    Description

    Proof of concept: PoC

    According to the comments in the code documenting the top-up mechanism. When a user's committed balance falls below minCommitted, the system attempts to commit more shares from their uncommitted balance. However, this is not always true.

    When the _spendFromCommitted function processes an agent's spend, it computes fundsAvailable as the caller’s current committed balance minus any holds and only invokes _tryTopUp if the requested shares exceed that pre‑spend amount.

    Consequently, as long as the agent keeps each transfer less than or equal to the balance that is already committed, the top‑up path never runs. The committed balance is decremented immediately without ever triggering a top-up from the user's uncommitted funds.

    This defeats the intended guarantee that the auto‑top‑up keeps at least minCommitted shares available.

    Recommendation

    Trigger the top-up when the updated fundsAvailable is below the shares + minCommited

    Resolution

    FastLane Team: The issue was resolved in PR#573.

  5. M-02 Medium Agent Instant-Uncommit Can Be Bypassed Trust Assumptions Resolved
    Location
    src/shmonad/ShMonad.sol: 131
    Round
    Main Review

    Description

    The agentTransferFromCommited function prevents agents from uncommitting their own balance from a policy.

    However, a malicious agent can shuffle their committed balance to any non-agent function they own using this function then immediately after call agentTransferToUncommitted to transfer the funds back to themselves. Bypassing the protection.

    Recommendation

    Prevent agents from transferring their own committed balances to non-agent addresses without a delay.

    Resolution

    FastLane Team: The issue was resolved in PR#586.

  6. M-03 Medium Single-Failure Deactivation Of New Validators Logical Error Resolved
    Location
    StakeTracker.sol: 711
    Round
    Main Review

    Description

    The _rollValidatorEpochForwards function advances the validator epoch state and updates accounting for each validator. At the end of this process, a validator is queued for deactivation when both s_validatorData[valId].inActiveSet_Last and s_validatorData[valId].inActiveSet_Current are false.

    However, newly added validators start with s_validatorData[valId].inActiveSet_Current at its default value of false, which then causes _advanceActiveSetFlags to set inActiveSet_Last to false.

    If the first crank involving that validator experiences a failure that sets inActiveSet_Current to false again, the deactivation condition is met immediately.

    As a result, the validator can be deactivated after just one failure, bypassing the intended two-strike mechanism that applies once a validator has prior epoch history. This situation can occur when a validator is added on Monad during the delay window and becomes active in epoch n+2.

    Recommendation

    Ensure newly added validators are initialized with appropriate active set state or implement a grace period before applying standard deactivation logic, preventing unintended single-failure removals.

    Resolution

    FastLane Team: The issue was resolved in PR#552.

  7. M-04 Medium Withdrawal Rewards Misclassified As Surplus Rewards Resolved
    Location
    StakeTracker.sol: 1244
    Round
    Main Review

    Description

    Proof of concept: PoC

    The _handleCompleteDecreasedAllocation function compares the received amount from a withdrawal to the undelegated expectedAmount and books any excess as “surplus.” On Monad, a withdrawal can include both the returned principal and rewards accumulated during the exit window.

    As a result, the excess over expectedAmount often represents earned rewards rather than surplus capital. Currently, this excess is redirected and potentially re-staked through queueToStake, bypassing the standard reward handling path.

    This causes validator rewards to be misclassified as surplus, skipping commission, underreporting earnedRevenue, and distorting validator performance metrics used for allocation.

    Recommendation

    When amount > expectedAmount, treat amount - expectedAmount as validator rewards and process it through the same reward-handling logic used in _handleEarnedStakingYield to ensure proper accounting.

    Resolution

    FastLane Team: The issue was resolved in PR#549.

  8. M-05 Medium Boosting From Committed Shares Logical Error Resolved
    Location
    ShMonad.sol: 235
    Round
    Main Review

    Description

    agentBoostYieldFromCommitted calls _handleBoostYield, which books revenue and increases queueToStake even though no new MON arrives and the committed shares are already staked.

    During crank(), this synthetic queueToStake can be netted in _settleGlobalNetMONAgainstAtomicUnstaking or reduced alongside queueForUnstake in _offsetLiabilitiesWithDeposits when there are uncovered liabilities and sufficient current assets, pushing genuine withdrawals or other payments into later epochs.

    The call also inflates globalRewardsPtr_N(0).earnedRevenue. The instant-withdraw path relies on that value as an offset: if distributed + netAmount > allocated, it will allow the withdrawal so long as allocated + earnedRevenue covers it, then enqueue the shortfall to queueForUnstake and increase allocatedAmount.

    When earnedRevenue was boosted without new cash, the contract still pays out immediately by drawing on liquid assets. This overstates near-term liquidity and forces liquidity to come from other areas, such as reservedAmount, until the backfilling unstake settles.

    Recommendation

    Keep queueForUnstake and revenue tied to real inflows so staking, withdrawals, and instant-withdraw capacity reflect actual liquidity.

    Resolution

    FastLane Team: The issue was resolved in PR#580.

  9. M-06 Medium MEV Through Atomic Withdrawals MEV Partially resolved
    Location
    src/shmonad/StakeTracker.sol: 1486
    Round
    Main Review

    Description

    The atomic unstake paths (withdraw, redeem, agentWithdrawFromCommitted) price every withdrawal off the current utilization snapshot returned by _getLiquidityForAtomicUnstaking.

    Each successful instant unstake immediately increments s_atomicAssets.distributedAmount in _accountForWithdraw so the next withdraw is repriced at a higher fee rate.

    An MEV bot that holds shMON can observe large instant withdrawals in the mempool and front-run them with a tiny redeem/withdraw first to push utilization up and pocket the victim's extra fee via it's remaining shMON balance.

    Because the fee curve is affine (fee(u) = c + m·u) with m = 1% by default, even a small utilization bump significantly raises the victim’s fee. A holder with just ~1% of the supply already profits from this attack.

    Recommendation

    Add slippage controls to every instant-unstake path so users aren’t forced to execute at a worse fee than they previewed.

    Resolution

    FastLane Team: The issue was resolved in PR#583.

  10. M-07 Medium solveGrossGivenNet Overflow Math Resolved
    Location
    src/shmonad/libraries/FeeLib.sol: 311
    Round
    Main Review

    Description

    Proof of concept: PoC

    When solveGrossGivenNet (used by withdraw and previewWithdraw) enters the quadratic branch, it computes (2*m*RAY*targetNet)/L inside Math.mulDiv, any withdrawal request with a targetNet greater than (2^256-1)/(2*m*RAY) will result in an overflow.

    With DEFAULT_SLOPE_RATE_RAY m = 1% (1e25), any withdrawal request above 5.79M MON causes the intermediate product to exceed 256 bits, so Math.mulDiv reverts with MulDivFailed() before applying liquiditycaps or fees.

    Raising the slope rate will proportionally reduce the overflow threshold.

    Recommendation

    Replace the 256-bit function mulDiv with the 512-bit version fullMulDiv

    Resolution

    FastLane Team: The issue was resolved in PR#532.

  11. M-08 Medium Double Liability Deduction In Target Stake Logical Error Resolved
    Location
    AccountingLib.sol: 297
    Round
    Main Review

    Description

    The targetStakedAmount function determines the protocol’s next staking allocation based on available equity after accounting for required float.

    It computes totalEquity as _totalStaked + nativeTokenBalance - totalLiabilities, where totalLiabilities includes liabilities.redemptionsPayable, liabilities.rewardsPayable, and admin.commissionPayable.

    To derive the staking target, the function subtracts _targetAtomicAllocatedAmount and workingCapital.reservedAmount from this equity.

    The issue arises because some liabilities are already reflected in both components. In _handleValidatorRewards, when liabilities.rewardsPayable increases, reservedAmount also increases by the same value.

    Similarly, _handleCompleteDecreasedAllocation tops up currentLiabilities to cover liabilities.redemptionsPayable, which are also reserved until redemption.

    As a result, the targetStakedAmount calculation deducts these amounts twice, once as liabilities and again through reserved assets leading to an understated staking target and potential underutilization of available equity.

    Recommendation

    Adjust the targetStakedAmount computation to avoid double-counting obligations that are already captured in both totalLiabilities and reservedAmount.

    Resolution

    FastLane Team: The issue was resolved in PR#589.

  12. M-09 Medium Inactive Validator's Pending Stake Not Unstaked Logical Error Resolved
    Location
    StakeTracker.sol: 638-650
    Round
    Main Review

    Description

    When cranking an inactive validator at (StakeTracker.sol:638-650) the protocol unstakes only _validatorUnstakableAmount and sets _nextTargetStakeAmount = 0.

    However, _validatorUnstakableAmount is calculated as targetStakeAmount - pendingStaking. This means the pendingStaking amount would be excluded from the decrease allocation that should happen for same validator next epoch, leaving pending funds unaccounted.

    Example:

    Validator has 100 ETH target + 10 ETH pending. When inactive, only 90 ETH is queued for unstaking and nextTarget becomes 0.

    The 10 ETH pending stake remains locked indefinitely.

    Recommendation

    Consider assigning difference between previous targetStakeAmount and _validatorUnstakableAmount as next target stake amount.

    Resolution

    FastLane Team: The issue was resolved in PR#563.

  13. L-01 Low Unused changeCommittedTotalSupply Best Practices Resolved
    Location
    Policies.sol: 473-474
    Round
    Main Review

    Description

    The changeCommittedTotalSupply boolean parameter in the internal _commitToPolicy() function is always passed as true in all three call sites (lines 55, 78, 185). The conditional check on line 513 serves no purpose as the flag is never set to false.

    function _commitToPolicy(
    uint64 policyID,
    address accountFrom,
    address sharesRecipient,
    uint256 shares,
    bool changeCommittedTotalSupply  // Always true
    )
    

    Recommendation

    Consider removing the changeCommittedTotalSupply parameter and the conditional check and always incrementing s_supply.committedTotal when committing shares.

    Resolution

    FastLane Team: The issue was resolved in PR#555.

  14. L-02 Low Lack Of Smart Contract Signatures Support Best Practices Resolved
    Location
    FLERC20.sol: 148-149
    Round
    Main Review

    Description

    The implementation assumes EOA signers and does not support ERC-4337 smart contract wallets or other account abstraction methods that are becoming increasingly common.

    Recommendation

    Consider adding support for ERC-1271: Smart contract signatures.

    Resolution

    FastLane Team: The issue was resolved in PR#578.

  15. L-03 Low Commission Reapplied In Process Logical Error Resolved
    Location
    Coinbase.sol: 61
    Round
    Main Review

    Description

    The process function calculates commission based on the coinbase contract’s entire current balance, distributes the commission, and then attempts to send the remaining rewards to delegators. If the payout to delegators fails, those untransferred rewards remain in the contract.

    On the next process call, commission is recalculated on the full balance again, including the previously retained rewards. This effectively applies commission again to the same rewards, reducing the amount available to delegators.

    Recommendation

    Ensure commission is only calculated on newly accrued rewards, excluding any balance carried over from prior failed distribution attempts.

    Resolution

    FastLane Team: The issue was resolved in PR#551.

  16. L-04 Low Access Control: denullifyEligibilityMap() Access Control Resolved
    Location
    ValidatorRegistry.sol: 345
    Round
    Main Review

    Description

    denullifyEligibilityMap is public with no access control, allowing anyone to modify s_valEligibility mapping.

    The s_valEligibility mapping (Storage.sol:102) is never read in any protocol logic.

    Only used in a view function getter, suggesting it's dead code.

    Protocol confirmed in discussions that this is a deadcode.

    Recommendation

    Consider removing the s_valEligibility mapping and associated code.

    Resolution

    FastLane Team: The issue was resolved in PR#570.

  17. L-05 Low Dead Code: _queueNetDepositsForStaking() Best Practices Resolved
    Location
    StakeTracker.sol: 1460
    Round
    Main Review

    Description

    The internal function _queueNetDepositsForStaking() at StakeTracker.sol: 1460 is defined but has no callers in the codebase.

    function _queueNetDepositsForStaking(uint256 amount) internal {
    // Function body exists but is never called
    }
    

    Recommendation

    Consider removing deadcode.

    Resolution

    FastLane Team: The issue was resolved in PR#579.

  18. L-06 Low Preview And Max Withdraw Drift Rounding Resolved
    Location
    src/shmonad/FLERC4626.sol: 139
    Round
    Main Review

    Description

    maxWithdraw reports a net-asset amount based on _convertToAssets(..., Floor) while previewWithdraw inverts the path with _convertToShares(..., Ceil) to cover fees.

    As a result the preview rounds the required shares up, so a user can request assets equal to maxWithdraw yet be asked to burn more shares than they actually have in balance.

    Recommendation

    Align the runtime withdraw flow with the same rounding and liquidity-aware math used in maxWithdraw.

    Resolution

    FastLane Team: Given recent exploits involving rounding and precision issues, we are purposefully making these rounding choices in favor of the security of the protocol.

    We round down when users specify shares, to give them a conservative amount of assets they can definitely withdraw (maxWithdraw).

    We round up when users specify assets, to give them a conservative number of shares they need to burn to get those assets (previewWithdraw).

    In both cases we round in favor of the protocol and not the user. Changing either of these would sometimes round in favor of the user, and even though its fairly inconsequential (1 wei), we think we should always round defensively in favor of the protocol.

  19. L-07 Low Incorrect Unique Block.coinbase Assumption Unexpected Behavior Resolved
    Location
    StakeTracker.sol: 593-604
    Round
    Main Review

    Description

    When cranking the linked list of validators, validators are primarily identified using their block.coinbase addresses which are stored in this linked list.

    Each of those addresses is passed into StakeTracker._crankValidator(), and an associated validator ID is looked up. That validator ID is then passed into any internal functions.

    This assumes that validator ID <> coinbase is strictly 1:1 relationship. However that assumption is incorrect - validator IDs are unique but many IDs can map to the same coinbase address.

    This means that the same coinbase address could be used multiple times in that linked list, which would always map back to the same validator ID.

    This relationship is protected at the stage where the owner adds validators to ShMonad, but in reality we will need to support validators that map to the same coinbase.

    Recommendation

    Refactor the linked list and any other identifying logic to always use validator ID, and only look up coinbase from ID at the point where it is required in the logic (e.g. to call process())

    Resolution

    FastLane Team: The issue was resolved in PR#593.

Discussion

18 findings
  1. D-01 Informational Direct Delegation For Risk-Free Rewards Rewards Acknowledged
    Location
    StakeTracker.sol: 880-881
    Round
    Discussion

    Description

    A malicious actor can frontrun MEV reward distribution by directly delegating to validators via the Monad staking precompile immediately after MEV is earned in epoch N.

    Since FastLane distributes MEV rewards to validators in epoch N+1 (via externalReward()), and direct delegations become active in N+1, the attacker receives a proportional share of these rewards without taking any performance risk.

    This creates a risk-free profit opportunity that can be exploited repeatedly every epoch.

    Example scenario:

    Epoch 5: MEV reward is generated for validator V1.

    Epoch 6 crank starts:

    • Global crank passes.
    • Validator crank begins.
    • Validator rewards from Epoch 5 are credited to V1.

    Since it is deterministic that the reward will be credited in Epoch 6, an attacker can delegate to V1 in Epoch 5, just before the epoch boundary, and receive a share of the reward without having participated in validation.

    Attackers can repeatedly capture validator MEV rewards with no stake-performance exposure.

    Recommendation

    If considered an issue, a straightforward mitigation would be to send validator rewards immediately as they are earned, rather than deferring to the next epoch.

    However, this may require a broader architectural refactor of the accounting.

    Other solution is enforcing that sendValidatorRewards is only called during boundary period of epoch, hence even if someone notices and delegates later, their delegation would be active from n+2.

    Resolution

    FastLane Team: 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.

  2. D-02 Informational Validators Can Game Stake Allocation Gaming Acknowledged
    Location
    StakeAllocationLib.sol: 67-72 StakeTracker.sol: 444-449
    Round
    Discussion

    Description

    Validators can manipulate stake allocation by:

    Self-delegate large amounts on Monad (not via FastLane) Call sendValidatorRewards() to spike their earnedRevenue Next epoch's allocation uses avg of epochs n-1, n-2 Validator receives outsized share of queueToStake

    Attack Economics:

    Validator with 10 FastLane stake, self-delegates 80 (total 100) Donates X rewards → loses only 20% (10% to FastLane, 10% to others) Gains larger stake allocation worth more than 20% cost Most profitable in early stages when FastLane share is small

    Code:

    // StakeAllocationLib.sol:67-72
    targetDelta = queueToStake * validatorRevenue / globalRevenue;
    // StakeTracker.sol:444 - uses n-1, n-2 epochs
    earnedRevenueLast: globalRewardsPtr_N(0).earnedRevenue    // n-1
    earnedRevenueCurrent: globalRewardsPtr_N(-1).earnedRevenue // n-2
    

    Recommendation

    Use n-2, n-3 epochs instead of n-1, n-2 to add 1-epoch delay, preventing immediate manipulation.

    Resolution

    FastLane Team: This finding is a design choice of shMonad. Validators can freely bid for deposits each crank. You can monitor validator bidding here:

    https://analytics.shmonad.xyz/stake-distribution

    Validators must keep in mind two costs:

    1. The Fastlane fees taken on rewards
    2. The validator bid would have to be sustained otherwise the algorithm will naturally start undelegating from that validator as soon as they

    stop bidding

    This design allows shMonad holders to capture increase yield from validators bidding for deposits

    Please note:

    • We’re not planning to change the smoothing window (n‑1/n‑2 ➝ n‑2/n‑3); adding another delay would slow legitimate 37 responsiveness without preventing repeated donations.
    • Operationally, we’ll monitor bidding strategies from validators and may change the algorithm in the future
  3. D-03 Informational Any Agent Can Disable Policy For All Agents Access Control Acknowledged
    Location
    Holds.sol: 150-151
    Round
    Discussion

    Description

    disablePolicy() function allows any single policy agent to irreversibly disable the entire policy, affecting all other agents and users.

    Recommendation

    Consider allowing only primary agents to disable policy

    Resolution

    FastLane Team: This is a design choice of the agent and policy system. Appointing agents is a permissioned action that we control, so any agent is a trusted entity.

  4. D-04 Informational boostYield() Attributes To Current Validator Rewards Acknowledged
    Location
    StakeTracker.sol: 1168-1169
    Round
    Discussion

    Description

    When boostYield() is called, it attributes the boosted yield to the current active validator's earnedRevenue, even though the boost may be unrelated to that validator's performance. This skews validator performance metrics.

    Recommendation

    Consider not attributing boosts to the current validator.

    Resolution

    FastLane Team: This is a design choice of the system. The function should always attribute to the current block’s producer. If someone wants to attribute earnings to a different validator but still give all earnings to shMON holders, they should call sendValidatorRewards() with a 100% fee and specify a validatorID

  5. D-05 Informational Escrow Duration Locks Funds Validation Acknowledged
    Location
    src/shmonad/Policies.sol: 279
    Round
    Discussion

    Description

    createPolicy(uint48 escrowDuration) accepts any 48‑bit duration without bounds. A policy owner can set escrowDuration near type(uint48).max (~9e13 blocks), and anyone who later deposits/commits into that policy will be unable to finish completeUncommit in any practical time frame.

    Because escrowDuration is immutable per policy and users often rely on pre-existing policies, this acts as an effectively permanent lock on their liquidity.

    Recommendation

    Enforce a sane upper bound when creating policies.

    Resolution

    FastLane Team: The issue was resolved in PR#554. This is a design choice of the system, specifically to support restaking usecases built on top of ShMonad policies which require explicit approval to release funds, and should not be circumvented by a finite escrow duration.

  6. D-06 Informational Redundant realTotalSupply() Function Best Practices Acknowledged
    Location
    FLERC20.sol: 56-57
    Round
    Discussion

    Description

    Both totalSupply() and realTotalSupply() return identical values by calling the same internal function _realTotalSupply().

    Unlike previous versions where these functions returned different values, they are now redundant. This adds unnecessary code and potential confusion for integrators.

    function totalSupply() public view returns (uint256) {
    return _realTotalSupply();
    }
    function realTotalSupply() external view returns (uint256) {
    return _realTotalSupply(); // @audit why two supplies only one is fine
    }
    

    Recommendation

    Consider removing the realTotalSupply() function entirely and rely solely on the standard totalSupply() function for consistency with ERC20 standards.

    Resolution

    FastLane Team: This is left in intentionally to support isolated staking (to a specific validator). This version of shMonad has removed the feature, but we intend to add it back in a future upgrade.

  7. D-07 Informational removePolicyAgent Assigns Last Agent As Prime Configuration Acknowledged
    Location
    Policies.sol: 872-873
    Round
    Discussion

    Description

    In _removePolicyAgent, when the primary agent is removed, the function assigns whoever was last in the array as the new primary. This is arbitrary - the last agent may not be qualified or authorized for the primary role.

    Recommendation

    Consider passing flag for who should be next primary agent.

    Resolution

    FastLane Team: The design of the agent and policy system does not differentiate between “primary agents” and other agents and there is no intention to differentiate between the two. We added a “primary agent” to save gas as it is included in the initial SLOAD of the Policy struct while other agents would require another SLOAD in the modifier check.

  8. D-08 Informational yieldOriginator Parameter Can Be Spoofed Informational Acknowledged
    Location
    FLERC4626.sol: 55-56
    Round
    Discussion

    Description

    The yieldOriginator address is user-provided input in boostYield(), allowing anyone to claim yield came from any address. This could confuse UIs, indexers, or analytics tracking yield sources

    Recommendation

    Consider validating yieldOriginator is msg.sender or beware of this risk associated.

    Resolution

    FastLane Team: This is a design choice of the protocol. yieldOriginator is for our own internal tracking via events, and is intended to be the bundler or DAppControl address of the Atlas metacall that generated yield. Atlas is not part of this audit, but you can learn more about Atlas here:

    https://www.atlasevm.com/

  9. D-09 Informational Revenue Offset Creates Friction For Withdrawals Informational Acknowledged
    Location
    FLERC4626.sol: 321-322
    Round
    Discussion

    Description

    The _recentRevenueOffset() mechanism deducts recent revenue from equity calculations during withdrawals, meaning legitimate users who need to withdraw after revenue events receive less value. While this protects against JIT attacks, it creates UX friction for normal innocent users.

    Recommendation

    Be aware this is a tradeoff between security and user experience. Consider documenting this behavior clearly for users.

    Resolution

    FastLane Team: This is intentional design of the system and we will make it clearly documented to users of the protocol.

  10. D-10 Informational Escrow Period Bypassed Informational Acknowledged
    Location
    Policies.sol: 674-675
    Round
    Discussion

    Description

    When a user requests uncommit (triggering escrow period), agents can still immediately use those uncommitting funds via _spendFromCommitted() without checking if escrow has completed.

    Potentially confusing users who expect their funds to be locked during escrow and later available for withdrawal.

    Recommendation

    Consider documenting that escrow only restricts users, not agents

    Resolution

    FastLane Team: This is intentional design of the system and we will make it clearly documented to users of the protocol.

  11. D-11 Informational depositAndCommit No Deposit Share Return Best Practices Resolved
    Location
    /src/shmonad/Policies.sol: 61
    Round
    Discussion

    Description

    While the deposit function returns the number of shares minted for the amount of MON sent. The depositAndCommit function which does the same but committing the shares afterwards doesn't.

    Leading to difficulties for other integrators to know the number of minted shares.

    Recommendation

    Return sharesMinted as part of the function.

    Resolution

    FastLane Team: The issue was resolved in PR#556.

  12. D-12 Informational Atomic Pool Init Defaults Best Practices Acknowledged
    Location
    /src/shmonad/AtomicUnstakePool.sol: 22
    Round
    Discussion

    Description

    __AtomicUnstakePool_init() silently applies the hard-coded fee curve whenever both mRay and cRay are zero.

    On a fresh proxy that behavior is fine, but it also means you can’t feed custom parameters during initialization, the owner must overwrite the defaults immediately after the upgrade if he was to change it.

    Recommendation

    Consider accepting initializer inputs or documenting the post-init step.

    Resolution

    FastLane Team: The protocol has been deployed, so this function is no longer relevant.

  13. D-13 Informational Malicious Coinbase Address Can Lead To DOS Gas Griefing Resolved
    Location
    src/shmonad/StakeTracker.sol: 654
    Round
    Discussion

    Description

    addValidator(uint64 validatorId, address coinbase) lets the owner register any coinbase contract for a validator.

    During _crankValidator, the system calls that coinbase via ICoinbase(coinbase).process() with all remaining gas (src/shmonad/StakeTracker.sol: 684-699).

    Although wrapped in try/catch, the call isn’t gas-limited—if the callee deliberately burns all gas (e.g., endless loop or heavy computation), the outer _crankValidator runs out of gas before reaching the catch, causing the entire crank transaction to revert.

    Recommendation

    Before adding a validator, vet the coinbase contract (or use an audited template) to ensure its hooks can’t stall crank(). If you must interact with an untrusted implementation, wrap those calls in a bounded-gas/try-catch path so a misbehaving validator can’t brick the epoch processing.

    Resolution

    FastLane Team: The issue was resolved in PR#590.

  14. D-14 Informational N(delta) Wraps Arbitrary Offsets Validation Acknowledged
    Location
    src/shmonad/Storage.sol: 193
    Round
    Discussion

    Description

    Storage.N(int256 nDelta) just adds nDelta to s_admin.internalEpoch inside an unchecked block and applies % EPOCHS_TRACKED. There’s no guard that the caller stays within the tracked window.

    Passing nDelta = 8, -9, or any other value simply wraps around the 8-slot ring and hands back a slot that might be in active use.

    The codebase only calls *_Ptr_N() with small, hard-coded offsets ([-3 … +2]) today, but because the helper is exposed to the whole codebase and widely used, someone could add a future call with an unbounded or user-supplied delta.

    If they overshoot, they’ll silently read/write the wrong epoch bucket.

    Recommendation

    Document this risk properly or add bounds based on the number of epochs tracked.

    Resolution

    FastLane Team: We have no intention to add a user supplied delta to the system.

  15. D-15 Informational sum(balanceOf) = totalSupply - ERC20 Invariant Informational Acknowledged
    Location
    FLERC20.sol: 19-20
    Round
    Discussion

    Description

    Since a user's committed balance is excluded from balanceOf, it breaks the usual ERC20 invariant that the sum of all user balances equals the total supply.

    This won’t cause any issues per se, but it’s something to be aware of for analytics teams.

    Recommendation

    Consider documenting this

    Resolution

    FastLane Team: This is intentional design of the protocol and we will work with analytic or frontend teams to correctly account for user balances.

  16. D-16 Informational Revenue-Based Stake Allocation Creates Barrier Informational Acknowledged
    Location
    StakeAllocationLib.sol: 61-72
    Round
    Discussion

    Description

    The crank allocates queued stake to validators proportionally based on their earned/global revenue. This results in cold start problem initially.

    When all validators are newly added, both validator and global earned revenue are zero, resulting in no stake allocation. However, this is solvable by sending validator rewards or boosting yield to jumpstart the system.

    Now, once a group of validators has established their stake and revenue, new validators face significant barriers to entry:

    1. New validator joins with earnedRevenue = 0
    2. Receives 0 stake allocation: 0 / globalRevenue = 0%
    3. Without stake delegation, cannot earn organic revenue from validation alone.
    4. Must get revenue by sending validator rewards or triggering boost yield.

    Recommendation

    Be aware of this game-theoretic dynamic when operating FastLane.

    Resolution

    FastLane Team: The system is live, so no longer relevant.

  17. D-17 Informational Assumptions On Monad Staking Precompile Informational Acknowledged
    Location
    Global
    Round
    Discussion

    Description

    The protocol's PrecompileHelpers and related contracts make various assumptions about the Monad staking precompile's behavior.

    All testing is currently done against MockMonadStakingPrecompile, not the actual production precompile.

    If the production Monad staking precompile behaves differently from the mock, it could result in issues,

    Recommendation

    Beware of the risk associated. Once precompile is in production, consider running test suite with it and reviewing it for compatibility with helpers contract.

    Resolution

    FastLane Team: The system is live on both mainnet and testnet running against the actual pre-compile.

  18. D-18 Informational Dead Code In AccountingLib Best Practices Resolved
    Location
    AccountingLib.sol
    Round
    Discussion

    Description

    The targetAtomicAllocatedDelta and targetStakedDelta functions in AccountingLib are not used.

    Recommendation

    Consider removing deadcode.

    Resolution

    FastLane Team: The issue was resolved in PR#584.

Remediation Review

3 findings
  1. H-01 High Global Pending Updates Affect Allocations Logical Error Resolved
    Location
    StakeAllocationLib.sol: 88-89
    Round
    Remediation Review

    Description

    Proof of concept: PoC

    During the validator crank loop, validators are processed sequentially.

    When for a certain validator a undelegate() is called, it immediately increments s_globalPending.pendingUnstaking.

    This directly reduces globalUnstakableAmount (calculated as stakedAmount - pendingStaking - pendingUnstaking) for all subsequent validators in the same crank cycle.

    Which affects their allocations and can cause underflow and revert.

    The issue occurs because queueForUnstake is cached at the start of the crank, but globalUnstakableAmount is recalculated fresh for each validator using the live s_globalPending state.

    When validator N undelegates, it increases pendingUnstaking, which decreases globalUnstakableAmount for validator N+1. If validator N+1's allocation is calculated assuming the new globalUnstakableAmount, the formula:

    _stakeAllocationDecrease = queueForUnstake * validatorAmountAvailableToUnstake / globalAmountAvailableToUnstake

    can produce a _stakeAllocationDecrease larger than expected, leading to underflow at: targetValidatorStake = validatorEpoch_Last.targetStakeAmount - netAmount;

    Recommendation

    Consider caching pendingUnstaking at the start of the crank cycle

    Resolution

    FastLane Team: The issue was resolved in PR#611.

  2. M-01 Medium Agent Withdraw Burn Mismatch Logical Error Resolved
    Location
    src/shmonad/ShMonad.sol:221
    Round
    Remediation Review

    Description

    When agentWithdrawFromCommitted is called with amountSpecifiedInUnderlying == false, it converts the requested shares to gross assets and caps that gross via _getGrossCappedAndFeeFromGrossAssets.

    If the cap triggers (low liquidity), _assetsToReceive reflects only the capped gross minus fee, but _sharesToDeduct stays equal to the original amount.

    _spendFromCommitted then burns the full requested shares even though the pool delivered fewer assets, so the caller loses more shMON than the payout justifies

    Recommendation

    Recompute _sharesToDeduct from _grossAssetsCapped before calling _spendFromCommitted, or revert when _grossAssetsCapped < _grossAssetsWanted to avoid inconsistent burns.

    Resolution

    FastLane Team: The issue was resolved in PR#612.

  3. D-01 Informational MaxWithdraw Rounding Mismatch Logical Error Acknowledged
    Location
    FLERC4626.sol: 359
    Round
    Remediation Review

    Description

    Proof of concept: PoC

    maxWithdraw calculates a user’s withdrawable amount by converting their share balance through the forward path (shares → assets), which floors the result and then applies fees and caps.

    When the user later calls withdraw, the contract reverses that value using _previewWithdraw, which follows the inverse path (net assets → gross assets → shares) and uses ceiling rounding.

    Because the forward and inverse paths round differently, the amount returned by maxWithdraw can translate back into more shares than the user holds, causing _burn to revert with ERC20InsufficientBalance.

    Recommendation

    Align maxWithdraw with the path and rounding used in _previewWithdraw so the returned amount never maps back to more shares than the caller owns.

    Resolution

    FastLane Team: Acknowledged.

Invariants 59

The review's fuzzing suite asserted 59 invariants. 58 held and 1 did not.

Every invariant tested
IDInvariantResult
ACCT-01Asset delta equals Liability+Equity delta (fundamental accounting equation)Held
ACCT-02Deposit and withdrawal in same epoch results in loss (no round-trip profit withoutHeld
BALS-01donation) totalSupply >= totalCommittedAllPolicies + totalUncommittingAllPolicies +Held
BALS-02sum(uncommitted) shMonadTotalSupply == s_supply.totalHeld
BALS-03committedTotalSupply == s_supply.committedTotalHeld
BALS-04balanceOf(user) == s_balances[user].uncommittedHeld
BALS-05allocatedAmount >= 0 (pool liquidity non-negative)Held
CRANK-01Goodwill should be zero without donation (no untracked funds)Held
CRANK-02Goodwill during crank should be zero with all active validatorsBroken
INVAR-EPCBalance decreased within expected bounds across epoch; total assets cover liabilitiesHeld
POL-GLOB-01Sum of committed uncommitted and uncommitting balances <= total supplyHeld
POL-GLOB-02Policy committed balances match sum of user's committed for all policiesHeld
POL-GLOB-03Policy uncommitting balances match sum of user's uncommitting for allHeld
POL-GLOB-04policies Holds never exceed committed balancesHeld
POL-CMT-01Policy must be active and have at least the primary agentHeld
POL-CMT-02Committed balance of commit recipient should increase by sharesHeld
POL-CMT-03Uncommitted balance of sender should decrease by sharesHeld
POL-CMT-04Total committed supply should increase by committed sharesHeld
POL-CMT-05Total supply should stay the same after commitHeld
POL-DEP-CMT-01Total supply should increase by minted sharesHeld
POL-DEP-CMT-02Uncommitted balance should increase by (minted - committed) sharesHeld
POL-UNCMT-01Uncommit start block set and uncommitting amount increasedHeld
POL-UNCMT-02User must have sufficient unheld committed balanceHeld
POL-UNCMT-03Requesting uncommit reduces committed balance and totalsHeld
POL-UNCMT-04Requesting uncommit sets min balance to new valueHeld
POL-UNCMT-05Requesting uncommit increases total uncommittingHeld
POL-RUAC-01Completor is overwritten with supplied addressHeld
POL-RUAC-02Approval's share allowance increases by sharesHeld
POL-COMP-01block.number > uncommitStartBlock + escrowDurationHeld
POL-COMP-02User must have enough uncommitting balance before completionHeld
POL-COMP-03Deducts from uncommitting and credits user's uncommittedHeld
POL-CUWA-01Only approved completor may complete uncommitHeld
POL-CUWA-02Finite approval shares must cover and decrement by shares completedHeld
POL-CUWA-03Infinite approvals (type(uint96).max) are never decrementedHeld
AGT-GLOB-01Held should be transient no held balances in pre-stateHeld
AGT-01Policy is active and has an agentHeld
AGT-02Current actor is an authorized agent for the policyHeld
AG-HOLD-01Delta held equals requested shares after successful holdHeld
AG-HOLD-02Policy/global totals stay aligned after holdHeld
AG-HOLD-03Hold is bounded by committed (held <= committed)Held
AG-HOLD-04Hold handler must not change real balances or supplyHeld
AGT-REL-01Release calls decrease held by requested amountHeld
AGT-SUP-01Agent transfers never change total shMON supplyHeld
AGT-UNCOMM-01Spending from committed never increases uncommitting balanceHeld
AGT-TFC-01Committed >= held before spendingHeld
AGT-TFC-02Total committed supply never decreasesHeld
AGT-TFC-FLOW-01Destination receives exactly what leaves the source (committed path)Held
AGT-TFC-DEST-01Destination committed must increase when receiving committedHeld
AGT-TTU-FLOW-01Source committed delta mirrors policy delta (uncommitted path)Held
AGT-TTU-DEST-01Destination uncommitted balance increases when receivingHeld
AGT-TTU-AGENT-01Source account must not be a policy agentHeld
AGT-WITH-DEST-01Destination holds only ETH (no shMON balance changes)Held
AGT-WITH-SUP-01Total supply must strictly decrease on withdraw when amount > 0Held
AGT-WITH-ETH-01Contract ETH loss equals destination ETH gainHeld
AGT-WITH-SRC-01Source uncommitted balance can only decreaseHeld
AGT-BOOST-01Boost yield burns shMON but retains ETH in contractHeld
AGT-BOOST-02Source balances follow spend semanticsHeld
AGT-BOOST-03Committed totals reflect burned supply net of top-upHeld
AGT-BOOST-04Non-source actors remain unaffected during boostHeld

Put your code through the same review.

This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.

Get a quote