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
Scope
-
github.com/FastLane-Labs
65cb4bfeda2c329cb98e
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-
H-01 High Atomic Liquidity Fee Bypassed At Max Capacity Logical Error Resolved
Description
Proof of concept: PoC
The function
agentWithdrawFromCommittedis 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
isUnderlyingargument that allows the caller to request the withdrawal to be denominated in MON.When the
isUnderlyingargument 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
isUnderlyingargument is set to true the function is meant to take the specified amount as net and calls_getGrossAndFeeFromNetAssetsreturning 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
netAmountto 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 calculatinggross - feeand 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.
-
H-02 High Withdrawals Exceed Intended Limits Logical Error Resolved
Description
Proof of concept: PoC
The
_accountForWithdrawfunction manages accounting for instant withdrawals from the atomic unstaking pool. It allows withdrawals up tos_atomicAssets.allocatedAmountplusglobalRewardsPtr_N(0).earnedRevenue.The issue arises when
allocatedAmountis depleted and a shortfall is covered usingearnedRevenue. In this case, the function increasesallocatedAmountby the shortfall amount.As a result, subsequent checks compare
_distributedAmount + netAmountagainst 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
_accountForWithdrawto properly account for already usedearnedRevenue, ensuring instant withdrawals cannot exceed the combined allocated andearnedRevenueamount.Resolution
FastLane Team: The issue was resolved in PR#587.
-
H-03 High Overflow DOS In Crank Locks Funds Logical Error Resolved
Description
In
StakeTracker._crankGlobal(), in the_checkSetNewAtomicLiquidityTargetcall, 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
_checkSetNewAtomicLiquidityTargetwe calculate_newScaledTargetPercentass_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
requestUnstakedcan never reach the epoch where they can callcompleteUnstake, so funds are stuck.Recommendation
Cap the calculated
_newScaledTargetPercentat 100%, and do an “emergency” decrease of allocated and distributed amounts in_afterRequestUnstakeif the unstaked amount + allocated + recent revenue is > total equity.Resolution
FastLane Team: The issue was resolved in PR#557.
-
M-01 Medium Auto Top-up Ignores minCommitted Threshold Unexpected Behavior Resolved
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
_spendFromCommittedfunction processes an agent's spend, it computesfundsAvailableas the caller’s current committed balance minus any holds and only invokes_tryTopUpif 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
minCommittedshares available.Recommendation
Trigger the top-up when the updated
fundsAvailableis below theshares + minCommitedResolution
FastLane Team: The issue was resolved in PR#573.
-
M-02 Medium Agent Instant-Uncommit Can Be Bypassed Trust Assumptions Resolved
Description
The
agentTransferFromCommitedfunction 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
agentTransferToUncommittedto 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.
-
M-03 Medium Single-Failure Deactivation Of New Validators Logical Error Resolved
Description
The
_rollValidatorEpochForwardsfunction advances the validator epoch state and updates accounting for each validator. At the end of this process, a validator is queued for deactivation when boths_validatorData[valId].inActiveSet_Lastands_validatorData[valId].inActiveSet_Currentare false.However, newly added validators start with
s_validatorData[valId].inActiveSet_Currentat its default value of false, which then causes_advanceActiveSetFlagsto setinActiveSet_Lastto false.If the first crank involving that validator experiences a failure that sets
inActiveSet_Currentto 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.
-
M-04 Medium Withdrawal Rewards Misclassified As Surplus Rewards Resolved
Description
Proof of concept: PoC
The
_handleCompleteDecreasedAllocationfunction compares the received amount from a withdrawal to the undelegatedexpectedAmountand 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
expectedAmountoften represents earned rewards rather than surplus capital. Currently, this excess is redirected and potentially re-staked throughqueueToStake, 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, treatamount - expectedAmountas validator rewards and process it through the same reward-handling logic used in_handleEarnedStakingYieldto ensure proper accounting.Resolution
FastLane Team: The issue was resolved in PR#549.
-
M-05 Medium Boosting From Committed Shares Logical Error Resolved
Description
agentBoostYieldFromCommittedcalls_handleBoostYield, which books revenue and increasesqueueToStakeeven though no new MON arrives and the committed shares are already staked.During
crank(), this syntheticqueueToStakecan be netted in_settleGlobalNetMONAgainstAtomicUnstakingor reduced alongsidequeueForUnstakein_offsetLiabilitiesWithDepositswhen 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: ifdistributed + netAmount > allocated, it will allow the withdrawal so long asallocated + earnedRevenuecovers it, then enqueue the shortfall toqueueForUnstakeand increaseallocatedAmount.When
earnedRevenuewas 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 asreservedAmount, until the backfilling unstake settles.Recommendation
Keep
queueForUnstakeand 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.
-
M-06 Medium MEV Through Atomic Withdrawals MEV Partially resolved
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.distributedAmountin_accountForWithdrawso 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.
-
M-07 Medium solveGrossGivenNet Overflow Math Resolved
Description
Proof of concept: PoC
When
solveGrossGivenNet(used by withdraw andpreviewWithdraw) enters the quadratic branch, it computes(2*m*RAY*targetNet)/LinsideMath.mulDiv, any withdrawal request with atargetNetgreater than (2^256-1)/(2*m*RAY) will result in an overflow.With
DEFAULT_SLOPE_RATE_RAYm = 1% (1e25), any withdrawal request above 5.79M MON causes the intermediate product to exceed 256 bits, soMath.mulDivreverts withMulDivFailed()before applyingliquiditycapsor fees.Raising the slope rate will proportionally reduce the overflow threshold.
Recommendation
Replace the 256-bit function
mulDivwith the 512-bit versionfullMulDivResolution
FastLane Team: The issue was resolved in PR#532.
-
M-08 Medium Double Liability Deduction In Target Stake Logical Error Resolved
Description
The
targetStakedAmountfunction determines the protocol’s next staking allocation based on available equity after accounting for required float.It computes
totalEquityas_totalStaked + nativeTokenBalance - totalLiabilities, wheretotalLiabilitiesincludesliabilities.redemptionsPayable,liabilities.rewardsPayable, andadmin.commissionPayable.To derive the staking target, the function subtracts
_targetAtomicAllocatedAmountandworkingCapital.reservedAmountfrom this equity.The issue arises because some liabilities are already reflected in both components. In
_handleValidatorRewards, whenliabilities.rewardsPayableincreases,reservedAmountalso increases by the same value.Similarly,
_handleCompleteDecreasedAllocationtops upcurrentLiabilitiesto coverliabilities.redemptionsPayable, which are also reserved until redemption.As a result, the
targetStakedAmountcalculation 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
targetStakedAmountcomputation to avoid double-counting obligations that are already captured in bothtotalLiabilitiesandreservedAmount.Resolution
FastLane Team: The issue was resolved in PR#589.
-
M-09 Medium Inactive Validator's Pending Stake Not Unstaked Logical Error Resolved
Description
When cranking an inactive validator at (
StakeTracker.sol:638-650) the protocol unstakes only_validatorUnstakableAmountand sets_nextTargetStakeAmount = 0.However,
_validatorUnstakableAmountis calculated astargetStakeAmount - pendingStaking. This means thependingStakingamount 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
nextTargetbecomes 0.The 10 ETH pending stake remains locked indefinitely.
Recommendation
Consider assigning difference between previous
targetStakeAmountand_validatorUnstakableAmountas next target stake amount.Resolution
FastLane Team: The issue was resolved in PR#563.
-
L-01 Low Unused changeCommittedTotalSupply Best Practices Resolved
Description
The
changeCommittedTotalSupplyboolean 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
changeCommittedTotalSupplyparameter and the conditional check and always incrementings_supply.committedTotalwhen committing shares.Resolution
FastLane Team: The issue was resolved in PR#555.
-
L-02 Low Lack Of Smart Contract Signatures Support Best Practices Resolved
Description
The implementation assumes EOA signers and does not support
ERC-4337smart 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.
-
L-03 Low Commission Reapplied In Process Logical Error Resolved
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.
-
L-04 Low Access Control: denullifyEligibilityMap() Access Control Resolved
Description
denullifyEligibilityMapis public with no access control, allowing anyone to modifys_valEligibilitymapping.The
s_valEligibilitymapping (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_valEligibilitymapping and associated code.Resolution
FastLane Team: The issue was resolved in PR#570.
-
L-05 Low Dead Code: _queueNetDepositsForStaking() Best Practices Resolved
Description
The internal function
_queueNetDepositsForStaking()atStakeTracker.sol: 1460is 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.
-
L-06 Low Preview And Max Withdraw Drift Rounding Resolved
Description
maxWithdrawreports a net-asset amount based on_convertToAssets(..., Floor)whilepreviewWithdrawinverts 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
maxWithdrawyet 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.
-
L-07 Low Incorrect Unique Block.coinbase Assumption Unexpected Behavior Resolved
Description
When cranking the linked list of validators, validators are primarily identified using their
block.coinbaseaddresses 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-
D-01 Informational Direct Delegation For Risk-Free Rewards Rewards Acknowledged
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 opportunitythat 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
sendValidatorRewardsis 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.
-
D-02 Informational Validators Can Game Stake Allocation Gaming Acknowledged
Description
Validators can manipulate stake allocation by:
Self-delegate large amounts on Monad (not via FastLane) Call
sendValidatorRewards()to spike theirearnedRevenueNext epoch's allocation uses avg of epochs n-1, n-2 Validator receives outsized share ofqueueToStakeAttack 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-2Recommendation
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:
- The Fastlane fees taken on rewards
- 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
-
D-03 Informational Any Agent Can Disable Policy For All Agents Access Control Acknowledged
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.
-
D-04 Informational boostYield() Attributes To Current Validator Rewards Acknowledged
Description
When
boostYield()is called, it attributes the boosted yield to the current active validator'searnedRevenue, 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 -
D-05 Informational Escrow Duration Locks Funds Validation Acknowledged
Description
createPolicy(uint48 escrowDuration)accepts any 48‑bit duration without bounds. A policy owner can setescrowDurationneartype(uint48).max (~9e13 blocks), and anyone who later deposits/commits into that policy will be unable to finishcompleteUncommitin any practical time frame.Because
escrowDurationis 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.
-
D-06 Informational Redundant realTotalSupply() Function Best Practices Acknowledged
Description
Both
totalSupply()andrealTotalSupply()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 standardtotalSupply()function for consistency withERC20standards.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.
-
D-07 Informational removePolicyAgent Assigns Last Agent As Prime Configuration Acknowledged
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.
-
D-08 Informational yieldOriginator Parameter Can Be Spoofed Informational Acknowledged
Description
The
yieldOriginatoraddress is user-provided input inboostYield(), allowing anyone to claim yield came from any address. This could confuse UIs, indexers, or analytics tracking yield sourcesRecommendation
Consider validating
yieldOriginatorismsg.senderor beware of this risk associated.Resolution
FastLane Team: This is a design choice of the protocol.
yieldOriginatoris 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: -
D-09 Informational Revenue Offset Creates Friction For Withdrawals Informational Acknowledged
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.
-
D-10 Informational Escrow Period Bypassed Informational Acknowledged
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.
-
D-11 Informational depositAndCommit No Deposit Share Return Best Practices Resolved
Description
While the
depositfunction returns the number of shares minted for the amount of MON sent. ThedepositAndCommitfunction 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
sharesMintedas part of the function.Resolution
FastLane Team: The issue was resolved in PR#556.
-
D-12 Informational Atomic Pool Init Defaults Best Practices Acknowledged
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.
-
D-13 Informational Malicious Coinbase Address Can Lead To DOS Gas Griefing Resolved
Description
addValidator(uint64 validatorId, address coinbase)lets the owner register any coinbase contract for a validator.During
_crankValidator, the system calls that coinbase viaICoinbase(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
_crankValidatorruns 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.
-
D-14 Informational N(delta) Wraps Arbitrary Offsets Validation Acknowledged
Description
Storage.N(int256 nDelta)just addsnDeltatos_admin.internalEpochinside 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.
-
D-15 Informational sum(balanceOf) = totalSupply - ERC20 Invariant Informational Acknowledged
Description
Since a user's committed balance is excluded from
balanceOf, it breaks the usualERC20invariant 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.
-
D-16 Informational Revenue-Based Stake Allocation Creates Barrier Informational Acknowledged
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:
- New validator joins with
earnedRevenue = 0 - Receives 0 stake allocation:
0 / globalRevenue = 0% - Without stake delegation, cannot earn organic revenue from validation alone.
- 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.
- New validator joins with
-
D-17 Informational Assumptions On Monad Staking Precompile Informational Acknowledged
Description
The protocol's
PrecompileHelpersand 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.
-
D-18 Informational Dead Code In AccountingLib Best Practices Resolved
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-
H-01 High Global Pending Updates Affect Allocations Logical Error Resolved
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 incrementss_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
queueForUnstakeis cached at the start of the crank, butglobalUnstakableAmountis recalculated fresh for each validator using the lives_globalPendingstate.When validator N undelegates, it increases
pendingUnstaking, which decreasesglobalUnstakableAmountfor validator N+1. If validator N+1's allocation is calculated assuming the newglobalUnstakableAmount, the formula:_stakeAllocationDecrease = queueForUnstake * validatorAmountAvailableToUnstake /globalAmountAvailableToUnstakecan produce a
_stakeAllocationDecreaselarger than expected, leading to underflow at:targetValidatorStake = validatorEpoch_Last.targetStakeAmount - netAmount;Recommendation
Consider caching
pendingUnstakingat the start of the crank cycleResolution
FastLane Team: The issue was resolved in PR#611.
-
M-01 Medium Agent Withdraw Burn Mismatch Logical Error Resolved
Description
When
agentWithdrawFromCommittedis called withamountSpecifiedInUnderlying == false, it converts the requested shares to gross assets and caps that gross via_getGrossCappedAndFeeFromGrossAssets.If the cap triggers (low liquidity),
_assetsToReceivereflects only the capped gross minus fee, but_sharesToDeductstays equal to the original amount._spendFromCommittedthen burns the full requested shares even though the pool delivered fewer assets, so the caller loses more shMON than the payout justifiesRecommendation
Recompute
_sharesToDeductfrom_grossAssetsCappedbefore calling_spendFromCommitted, or revert when_grossAssetsCapped < _grossAssetsWantedto avoid inconsistent burns.Resolution
FastLane Team: The issue was resolved in PR#612.
-
D-01 Informational MaxWithdraw Rounding Mismatch Logical Error Acknowledged
Description
Proof of concept: PoC
maxWithdrawcalculates 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
maxWithdrawcan translate back into more shares than the user holds, causing_burnto revert withERC20InsufficientBalance.Recommendation
Align
maxWithdrawwith the path and rounding used in_previewWithdrawso the returned amount never maps back to more shares than the caller owns.Resolution
FastLane Team: Acknowledged.
No findings match.
Invariants 59
The review's fuzzing suite asserted 59 invariants. 58 held and 1 did not.
Every invariant tested
| ID | Invariant | Result |
|---|---|---|
ACCT-01 | Asset delta equals Liability+Equity delta (fundamental accounting equation) | Held |
ACCT-02 | Deposit and withdrawal in same epoch results in loss (no round-trip profit without | Held |
BALS-01 | donation) totalSupply >= totalCommittedAllPolicies + totalUncommittingAllPolicies + | Held |
BALS-02 | sum(uncommitted) shMonadTotalSupply == s_supply.total | Held |
BALS-03 | committedTotalSupply == s_supply.committedTotal | Held |
BALS-04 | balanceOf(user) == s_balances[user].uncommitted | Held |
BALS-05 | allocatedAmount >= 0 (pool liquidity non-negative) | Held |
CRANK-01 | Goodwill should be zero without donation (no untracked funds) | Held |
CRANK-02 | Goodwill during crank should be zero with all active validators | Broken |
INVAR-EPC | Balance decreased within expected bounds across epoch; total assets cover liabilities | Held |
POL-GLOB-01 | Sum of committed uncommitted and uncommitting balances <= total supply | Held |
POL-GLOB-02 | Policy committed balances match sum of user's committed for all policies | Held |
POL-GLOB-03 | Policy uncommitting balances match sum of user's uncommitting for all | Held |
POL-GLOB-04 | policies Holds never exceed committed balances | Held |
POL-CMT-01 | Policy must be active and have at least the primary agent | Held |
POL-CMT-02 | Committed balance of commit recipient should increase by shares | Held |
POL-CMT-03 | Uncommitted balance of sender should decrease by shares | Held |
POL-CMT-04 | Total committed supply should increase by committed shares | Held |
POL-CMT-05 | Total supply should stay the same after commit | Held |
POL-DEP-CMT-01 | Total supply should increase by minted shares | Held |
POL-DEP-CMT-02 | Uncommitted balance should increase by (minted - committed) shares | Held |
POL-UNCMT-01 | Uncommit start block set and uncommitting amount increased | Held |
POL-UNCMT-02 | User must have sufficient unheld committed balance | Held |
POL-UNCMT-03 | Requesting uncommit reduces committed balance and totals | Held |
POL-UNCMT-04 | Requesting uncommit sets min balance to new value | Held |
POL-UNCMT-05 | Requesting uncommit increases total uncommitting | Held |
POL-RUAC-01 | Completor is overwritten with supplied address | Held |
POL-RUAC-02 | Approval's share allowance increases by shares | Held |
POL-COMP-01 | block.number > uncommitStartBlock + escrowDuration | Held |
POL-COMP-02 | User must have enough uncommitting balance before completion | Held |
POL-COMP-03 | Deducts from uncommitting and credits user's uncommitted | Held |
POL-CUWA-01 | Only approved completor may complete uncommit | Held |
POL-CUWA-02 | Finite approval shares must cover and decrement by shares completed | Held |
POL-CUWA-03 | Infinite approvals (type(uint96).max) are never decremented | Held |
AGT-GLOB-01 | Held should be transient no held balances in pre-state | Held |
AGT-01 | Policy is active and has an agent | Held |
AGT-02 | Current actor is an authorized agent for the policy | Held |
AG-HOLD-01 | Delta held equals requested shares after successful hold | Held |
AG-HOLD-02 | Policy/global totals stay aligned after hold | Held |
AG-HOLD-03 | Hold is bounded by committed (held <= committed) | Held |
AG-HOLD-04 | Hold handler must not change real balances or supply | Held |
AGT-REL-01 | Release calls decrease held by requested amount | Held |
AGT-SUP-01 | Agent transfers never change total shMON supply | Held |
AGT-UNCOMM-01 | Spending from committed never increases uncommitting balance | Held |
AGT-TFC-01 | Committed >= held before spending | Held |
AGT-TFC-02 | Total committed supply never decreases | Held |
AGT-TFC-FLOW-01 | Destination receives exactly what leaves the source (committed path) | Held |
AGT-TFC-DEST-01 | Destination committed must increase when receiving committed | Held |
AGT-TTU-FLOW-01 | Source committed delta mirrors policy delta (uncommitted path) | Held |
AGT-TTU-DEST-01 | Destination uncommitted balance increases when receiving | Held |
AGT-TTU-AGENT-01 | Source account must not be a policy agent | Held |
AGT-WITH-DEST-01 | Destination holds only ETH (no shMON balance changes) | Held |
AGT-WITH-SUP-01 | Total supply must strictly decrease on withdraw when amount > 0 | Held |
AGT-WITH-ETH-01 | Contract ETH loss equals destination ETH gain | Held |
AGT-WITH-SRC-01 | Source uncommitted balance can only decrease | Held |
AGT-BOOST-01 | Boost yield burns shMON but retains ETH in contract | Held |
AGT-BOOST-02 | Source balances follow spend semantics | Held |
AGT-BOOST-03 | Committed totals reflect burned supply net of top-up | Held |
AGT-BOOST-04 | Non-source actors remain unaffected during boost | Held |
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.
