90153 sc low cr 01 fix bypass maxmint converts the absolute cap instead of remaining capacity causing mint maxmint to revert after rewards
#90153 [SC-Low] CR-01 fix bypass: maxMint() converts the absolute cap instead of remaining capacity, causing mint(maxMint) to revert after rewards
Submitted on Aug 23rd 2026 at 11:20:19 UTC by @crypt0n1337 for Audit Comp | Firelight
Report ID: #90153
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Description
Bug Description
Brief/Intro
FirelightVault.maxMint() calculates the remaining share capacity as convertToShares(depositLimit) - totalSupply(). This subtracts shares only after converting the absolute asset cap at the current exchange rate. ERC-4626 conversion includes virtual assets/shares and integer rounding, so the conversion is not linear once rewards make totalAssets() differ from totalSupply().
In reachable post-reward states, maxMint() consequently overstates the executable capacity by one raw share. An unprivileged user who queries maxMint() and passes that exact advertised amount to mint() receives DepositLimitExceeded, even though ERC-4626 requires maxMint() never to exceed the amount that mint() will accept without reverting. Two attached local PoCs execute the real legacy-to-V2 upgrade, the real VaultRewardDistributor.distributeIncentive() path, the real maxMint() calculation, and the real failing mint() transaction. The primary PoC replays the exact publicly observable Phase-1 supply, assets, and deposit limit, then reaches the failing residue through a 0.001054 FXRP permissionless deposit. A one-share-lower negative control succeeds.
Affected asset and frozen snapshot
The selected in-scope asset is:
https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol
The report and PoC are pinned to:
Relevant frozen code:
Details
The vulnerable implementation is:
Let:
S = totalSupply();A = totalAssets();L = depositLimit; andR = L - A, the actual remaining asset capacity.
The inherited OpenZeppelin conversion uses a zero decimals offset, one virtual share, and one virtual asset. Therefore:
The current function returns:
However, mint(k) is executable under the deposit limit only when:
Thus, the actual maximum is:
Converting the absolute cap and then subtracting supply is not equivalent to converting the remaining asset capacity. When rewards make A > S, the extra rounding term can make reportedMax = executableMax + 1.
The PoC reaches the following state through ordinary contract calls:
mint(952,381) therefore reaches _depositFunds(), calculates total assets one unit above depositLimit, and reverts with DepositLimitExceeded. In the same state, mint(952,380) succeeds and emits Deposit.
Current deployed Phase-1 state replay
The stronger PoC pins the following read-only state from the deployed Phase-1 proxy 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3 at Flare block 68,068,112 (2026-08-23T10:45:27Z):
The local replay reaches that exact assets/supply ratio through the real VaultRewardDistributor after the intended V2 upgrade. Before the trigger, previewMint(maxMint) == maxDeposit, providing a safe-state negative control. An ordinary user then deposits only 1,054 raw FXRP (0.001054 FXRP) through the public deposit() function. It mints 1,053 raw shares and reaches:
The advertised amount therefore exceeds the remaining asset capacity by one raw unit and its real mint() call reverts. The next-lower mint succeeds. This replay uses no time warp, storage write, direct vault donation, impersonation, or attacker-controlled privileged call. The pinned Phase-1 values are public-state evidence only; every state transition in the PoC occurs on a fresh local chain.
Trigger flow
The intended upgrade retains the deployed Phase-1 vault's supply, asset balance, and deposit limit in the frozen V2
FirelightVaultimplementation.A normal user deposits
1,054raw FXRP through publicdeposit(), receiving1,053raw shares. No privileged action is part of this trigger.A second unprivileged user calls
maxMint(receiver)and receives3,775,222,712,536shares.previewMint(maxMint)requires3,775,494,314,025assets, whilemaxDepositand the real remaining capacity are only3,775,494,314,024.The user calls
mint(maxMint, receiver). The productionmint()and_depositFunds()path reverts withDepositLimitExceeded.As a negative control,
mint(3,775,222,712,535, receiver)succeeds, proving that the failure is the one-share overstatement rather than pause, blocklist, incident, allowance, balance, upgrade, or setup failure.
The secondary PoC independently reaches the same root from a small clean state via an ordinary 5% incentive and reproduces 952,381 advertised versus 952,380 executable shares.
Preconditions and feasibility
The V2 upgrade has occurred.
The asset/share exchange rate is above 1:1. The currently deployed Phase-1 vault already satisfies this condition at the pinned block.
The vault remains below its configured deposit limit and reaches a rounding boundary. From the pinned deployed state, the primary PoC reaches it through a
1,054-raw-unit public deposit without changing the configured limit.The receiver is not blocklisted, the vault is not paused, and there is no active incident.
An unprivileged user follows the standard
maxMint()->mint()integration pattern.
The finding does not require a malicious or incorrect action by a privileged role holder. The triggering state transition is a permissionless deposit. The production reward distributor is used only to reproduce the already-observed assets/supply ratio on a fresh local chain. There is no race, stale off-chain state, direct storage mutation, malicious token behavior, or public-chain transaction.
Why this is distinct from OpenZeppelin CR-01 and PR #9
The public OpenZeppelin report's CR-01 is titled "Limit Functions Not Overridden to Enforce Deposit/Withdraw Restrictions." Its factual root cause was that none of the four inherited ERC-4626 limit functions were overridden to account for the deposit limit, blocklist, or pause state. Its recommendation was to add overrides, and the report marks it resolved by PR #9 at commit ddb821b.
This report begins from the already-added maxMint() override and demonstrates a different defect inside the remediation: the new implementation converts the absolute cap before subtracting supply, which becomes incorrect after yield due to virtual offsets and rounding. Fixing this report requires replacing the new formula; merely adding the already-present override does not fix it.
The public PR #9 has no description, no review, and no discussion of this arithmetic condition or its impact. Its single public commit contains the current formula and tests maxMint == previewDeposit(maxDeposit) only while assets and shares remain at a 1:1 exchange rate. Those tests pass and do not disclose:
the post-reward
A > Scondition;the one-raw-share overstatement;
previewMint(maxMint) > maxDeposit; orthe resulting real
mint(maxMint)revert.
Accordingly, the cited audit and PR disclose the missing-override issue and its attempted remediation, not the ineffective-fix behavior demonstrated here. Immunefi's Firelight Code Freeze Assurance states that a bypass of a fix is a new, valid bug. Immunefi's general guidance likewise states that a previously found bug whose fix did not cover all exploitation means, or a new bug introduced by a fix, should be reported if the resulting impact is in scope.
Impact
The selected impact is:
Low — Contract fails to deliver promised returns, but doesn't lose value
ERC-4626 specifies that maxMint() must return the maximum number of shares that mint() will accept for the receiver without reverting, and it must not return a higher amount; underestimation is explicitly preferred when necessary. Here the vault advertises a maximum that its own production mint() rejects in the same state.
This can break wallets, routers, vault aggregators, or user scripts that use the standard boundary pattern shares = maxMint(receiver); mint(shares, receiver). The official Flare Firelight mint guide itself treats maxMint() as the capacity validation step before executing mint().
The impact is immediate and reproducible: the boundary mint reverts and the caller pays for a failed transaction. This report does not claim theft, insolvency, permanent or temporary freezing, loss of principal, or attacker profit. The overstatement is at most one raw share for this root cause, and retrying with the correct one-share-lower amount succeeds. Those limitations are why Low, rather than Medium or higher, is selected.
Risk Breakdown
Severity: Low
In-scope impact: Contract fails to deliver promised returns, but doesn't lose value
Affected caller: Any unprivileged minter using the advertised boundary
Required protocol state: Positive reward/yield, remaining deposit headroom, and a reachable rounding boundary
Privileges required by the triggering caller: None
User interaction: The user queries
maxMint()and sends the correspondingmint()transactionRepeatability: The failure is deterministic for the demonstrated state until totals or the deposit limit change
Funds at risk: None claimed
Recovery: The user can retry with a smaller amount; no funds are locked
Recommendation
Convert the remaining asset capacity to shares instead of converting the absolute cap and subtracting the existing supply. For example:
Equivalent reuse of previewDeposit(maxDeposit(receiver)) is also appropriate as long as all global and receiver-specific guards remain represented.
Add regression/invariant coverage for exchange rates both above and below 1:1, including ordinary rewards:
For a finite nonzero maximum, also verify that the next raw share is not accepted under the same limit:
References
Proof of Concept
Proof of Concept
What the PoC executes
The two PoCs are end-to-end on a fresh local Hardhat chain. They do not replace any security-relevant transition with a local variable or arithmetic-only model. Together, they:
deploy the repository's local FXRP test token;
deploys the actual
FirelightVaultPredepositsimplementation behind an upgradeable proxy;executes a real initial deposit;
upgrades that proxy to the frozen
FirelightVaultimplementation;executes
initializeV2()and verifiescontractVersion() == 2;deploys the actual frozen
VaultRewardDistributorbehind a proxy;grants it the vault's real
CHECKPOINT_ROLE;executes a real
distributeIncentive()token transfer and checkpoint;reads the resulting real
totalSupply(),totalAssets(),maxDeposit(),maxMint(),previewDeposit(), andpreviewMint()values;in the primary replay, executes the public
1,054-raw-unit deposit that moves the pinned state from a safe residue to the failing residue;sends the advertised
mint(maxMint)transaction and asserts the realDepositLimitExceededrevert; andsends the corrected negative-control mint and asserts a real
Depositevent and final assets not exceeding the cap.
No public testnet or mainnet is contacted.
Attachment and placement
Attach both supplied files to the report. After checking out the frozen repository, place them at:
Attachment SHA-256 values:
The secondary attachment is reproduced below. The primary attachment is included separately because it adds the pinned deployed-state replay and pre-trigger control.
Reproduction commands
Observed output
Security assertions demonstrated
The state is reached through the intended legacy-to-V2 deployment path.
The primary replay starts from the exact deployed Phase-1
S/A/Lvalues and proves the current residue is safe before an ordinary deposit reaches the bug.A real, ordinary 5% incentive changes the exchange rate; no direct donation shortcut is used.
No time warp, storage write, direct vault donation, or impersonation is used.
maxMint()advertises952,381shares.The advertised amount requires
1,000,001assets against1,000,000of remaining capacity.The real production
mint(maxMint)transaction reverts withDepositLimitExceeded.The negative-control
mint(952,380)transaction succeeds and leavestotalAssets() <= depositLimit.
Was this helpful?