88313 sc low maxmint floors through converttoshares while mint prices with ceil so the advertised maximum can exceed the deposit limit by the rounding residue
Submitted on Aug 12th 2026 at 17:49:34 UTC by @ret2basic for Audit Comp | Firelight
Report ID: #88313
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
Brief/Intro
ERC-4626 requires maxMint to return the largest number of shares mint will accept without reverting. FirelightVault.maxMint derives that figure by converting the deposit limit into shares, which rounds down, while mint prices those shares back into assets with Math.Rounding.Ceil and then tests the result against the same limit. Whenever the share price is above one — which is the normal state of this vault, and the state every reward distribution moves it further into — the two roundings pull in opposite directions and the advertised maximum is refused by the vault's own limit check.
Vulnerability Details
maxMint works in share units:
function maxMint(address receiver) public view override returns (uint256 amount) {
uint256 shares = totalSupply();
uint256 sharesLimit = convertToShares(depositLimit);
if (isBlocklisted[receiver] || paused() || _hasActiveIncident() || shares > sharesLimit) {
return 0;
} else {
return sharesLimit - shares;
}
}convertToShares floors: sharesLimit = floor(depositLimit × (S + 1) / (A + 1)).
mint converts back, rounding up, and enforces the limit in asset units:
so the call succeeds only if A + ceil((sharesLimit − S) × (A + 1) / (S + 1)) ≤ depositLimit.
Round-tripping depositLimit → shares → assets through a floor and then a ceil does not return the original when A > S, and the residue is on the wrong side of the comparison. The disagreement is structural, not a boundary artefact: it grows out of the share price, and it is maxDeposit — expressed in assets, the unit the limit is actually enforced in — that avoids it by never converting at all.
Reaching A > S requires nothing unusual. The live vault is already there: at the time of writing it holds 58,284,692.153737 fXRP against 58,280,499.270118 shares, a price of 1.000072, with depositLimit set to 60,168,000 fXRP — a limit close enough to bind. Every VaultRewardDistributor.distributeRewards and distributeIncentive call moves assets in without minting shares, pushing the price further above one.
Concrete case
From the PoC, with a deposit limit of 10, one deposit of 3 and 4 assets of rewards forwarded in:
7 + 4 = 11 > 10, so mint(2, bob) reverts DepositLimitExceeded on the amount the vault just advertised. The companion test shows maxDeposit in the same state is honoured exactly.
How often, measured two ways
Enumerating the same two formulas over every state reachable as deposit, then rewards forwarded in, for deposit limits 8 through 200, the advertised maximum is unmintable in 590,251 of 989,970 states — 59.6%. That figure describes small-magnitude vaults, where the virtual +1 offsets and the rounding residues are a large fraction of the numbers involved. It is not representative of this deployment, and it would be misleading to quote it alone.
Walking forward from the live vault's actual position instead — totalAssets 58,288,993,381,030, totalSupply 58,284,800,187,987, depositLimit 60,168,000,000,000, read at block 0x4021533 — through twenty thousand realistic deposits and reward distributions:
So at production magnitudes the residue is a single unit, and the defect is confined to the last unit or two of capacity: mint(maxMint(x), x) reverts when the vault is essentially full. Queried live at that block, maxMint returns 1,878,871,447,009 and pricing it costs exactly the remaining room — the current state is one of the working ones.
Impact Details
No funds are at risk and no attacker is involved. The cost is integration correctness, and it is narrow:
mint(maxMint(user), user)is the standard way for a router, zapper or aggregator to take the largest position a vault will accept. Against this vault it reverts when the vault is within a rounding unit of its limit — which is precisely the moment an integrator is most likely to be probing for the last of the capacity.Any integrator that trusts
maxMintas a bound — for sizing, for UI display, or for a simulation before submitting — is given a figure the vault will not honour, in that state.The vault otherwise presents itself as ERC-4626-compatible and documents its deviations precisely (
withdrawandredeemqueue rather than transfer, and theWithdrawevent is not emitted). This deviation is not among them, so an integrator reading the documentation has no warning.
Recommended fix
Compute the limit in the unit it is enforced in, then convert once, rounding in the direction that keeps the result acceptable:
This mirrors maxDeposit, which is already correct, and makes mint(maxMint(x), x) succeed by construction. Adding an assertion to the test suite that mint(maxMint(x), x) never reverts would keep it that way.
References
contracts/core/FirelightVault.sol—maxMint,maxDeposit,mint,_depositFunds,_previewTotals,_convertToSharesTotalsEIP-4626,
maxMint: "MUST return the maximum amount of shares mint would allow to be deposited to receiver and not cause a revert"Repository:
https://github.com/immunefi-team/audit-comp-firelight@42f9ea5e43d88b35197b901acc2a8c24b314fa62
Proof of Concept
Proof of Concept
Prerequisites
Toolchain:
forge 1.7.1(Foundry),solc 0.8.28, Node.js withnpm.No RPC required. Runs entirely on a local EVM; no environment variables or forked state.
Target:
https://github.com/immunefi-team/audit-comp-firelight, branchv1_audit_ready, commit42f9ea5e43d88b35197b901acc2a8c24b314fa62.Contract under test: the real
FirelightVaultbehind a realERC1967Proxy, initialized through its productioninitialize.
Attacker capabilities
None. There is no attacker. Two ordinary users deposit, and rewards arrive in the vault the way VaultRewardDistributor delivers them — as a plain asset transfer that mints no shares. The defect is in what the vault reports about itself.
No vm.store, no vm.etch, no role impersonation. All capital arrives by mint-then- deposit, or as the reward transfer being modelled.
Environmental doubles, disclosed
MockERC20as the vault asset (6 decimals, matching the live FXRP) — the project's own test double.Rewards are modelled as
asset.mint(address(vault), reward), which is exactly the stateVaultRewardDistributor.distributeRewardsproduces: assets in, no shares out. The distributor itself is not deployed, because the finding is inmaxMintversusmintand needs only the resulting share price.Small numbers (limit 10, deposit 3, reward 4) are used so the arithmetic is legible. The bounded fuzz covers limits up to 1,000, and the frequency figure in the report enumerates limits 8–200.
Reproduction steps
Clone the target at the pinned commit and install its dependencies:
Copy every file below into the matching path.
Run:
Expected output:
What each test establishes
test_mintOfMaxMintReverts— the target behaviour. The vault advertisesmaxMint(bob) == 2; pricing those two shares through the vault's ownpreviewMintcosts 4 assets;7 + 4 = 11exceeds the limit of 10; andmint(2, bob)revertsDepositLimitExceeded. The vault refuses the number it just published.test_depositOfMaxDepositSucceeds— the control, in the identical state.maxDepositis expressed in assets, the unit the limit is enforced in, so no conversion happens anddeposit(maxDeposit(bob), bob)succeeds with the limit still holding. This isolates the cause as the share/asset round trip inmaxMintrather than anything about the limit, the pause flags or the incident gate.testFuzz_maxMintIsOftenUnmintable— the same disagreement searched rather than hand-picked, over deposit limits up to 1,000. For each state it asserts the vault is in exactly one of two conditions: the advertised maximum mints, or it revertsDepositLimitExceeded. It never lands anywhere else, which is what makes the failure structural rather than incidental.
Frequency
Two enumerations of the same two formulas (convertToShares flooring, previewMint ceiling), reported together because quoting either alone misleads.
Over deposit limits 8 through 200 — small-magnitude vaults, where the virtual +1 offsets are a large share of the numbers — 989,970 states have a non-zero maxMint and 590,251 of them (59.6%) refuse it. That is the regime the tests above run in, chosen so the arithmetic is legible.
Walking forward from the live vault's position instead (totalAssets 58,288,993,381,030, totalSupply 58,284,800,187,987, depositLimit 60,168,000,000,000 at block 0x4021533) through twenty thousand realistic deposits and reward distributions, only 1 of 1,044 reachable states refuses it, and that state is totalAssets exactly one unit below the limit. At production magnitudes the residue is a single unit, so the defect is confined to the last unit or two of capacity. The report states it this way rather than quoting the 59.6%.
Complete runnable PoC files
poc-inputs/foundry.toml
poc-inputs/fuzz/Erc4626Limits.t.sol
Was this helpful?