88292 sc low maxmint can overstate remaining mint capacity and return an amount that mint immediately rejects
Submitted on Aug 12th 2026 at 16:36:04 UTC by @CarlosMB for Audit Comp | Firelight
Report ID: #88292
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
Summary
FirelightVault.maxMint() derives the remaining mint capacity by converting the absolute depositLimit to shares and then subtracting the current totalSupply():
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;
}
}This is not equivalent to converting the remaining asset capacity once the vault exchange rate is not exactly 1:1.
Firelight's ERC-4626 conversions include the virtual terms used by OpenZeppelin (+1 asset and +1 virtual share with the default decimals offset). Therefore:
can be larger than:
As a result, maxMint(receiver) can return a share amount whose previewMint() asset requirement exceeds the remaining deposit capacity. A fully funded and approved user calling:
then immediately reverts with DepositLimitExceeded.
This is reachable through normal protocol operation. A legitimate reward distribution increases totalAssets() without minting vault shares, causing totalAssets() != totalSupply().
Vulnerability Details
The deposit limit is enforced in _depositFunds() against the post-mint asset balance:
Therefore the actual mint capacity is determined by the remaining asset room:
and the maximum executable share amount is:
However, maxMint() currently computes:
These expressions are not equivalent because the conversion is not a simple linear assets * totalSupply / totalAssets mapping. Firelight inherits the OpenZeppelin virtual-asset/share terms.
For the default decimals offset, the relevant conversion is effectively:
Subtracting totalSupply() after converting the absolute limit therefore does not correctly represent the number of shares that can be minted using only the remaining asset capacity.
Concrete Example
Using the repository's 6-decimal asset:
The totalAssets > totalSupply state is reached normally by depositing 10 tokens and then forwarding 1 token of legitimate rewards through VaultRewardDistributor, which transfers vault assets directly to the vault without minting shares.
The current implementation computes:
and therefore:
However, minting those shares requires rounding the asset requirement up:
The vault only has 13,000,000 raw assets of remaining capacity:
Therefore:
and:
reverts with:
The actual maximum executable amount is:
Thus maxMint() overstates the protocol's real mint capacity by one raw share in this reproducible state.
Relation to the Published ERC-4626 Max-Method Fix
That public change contains the same maxMint() implementation:
Its tests compare maxMint() against the shares corresponding to maxDeposit(), but only exercise states where the exchange rate remains 1:1. In those states the incorrect formula happens to produce the expected result.
The audit snapshot contains the same implementation, but a normal reward distribution moves the vault away from 1:1 and exposes the remaining inconsistency.
This report therefore demonstrates an incomplete remediation / edge case in the published max-method fix rather than merely restating the previously fixed absence of ERC-4626 max methods.
Impact
maxMint() is intended to report the maximum number of shares that the vault can currently accept for a receiver.
In the demonstrated state, it returns a value that the vault itself rejects due to its own depositLimit, even when the caller:
is not blocklisted;
the vault is not paused;
there is no active incident;
the caller has sufficient underlying assets; and
the caller has sufficient allowance.
Integrations or users that rely on maxMint() to construct the largest valid mint transaction can therefore receive a protocol-provided value that deterministically reverts.
There is no loss of funds, so Low — Contract fails to deliver promised returns without loss of value is the appropriate impact.
Proof of Concept
Proof of Concept
Create:
with:
Run:
Expected security-relevant result:
Root Cause
maxMint() applies the asset-to-share conversion to the absolute deposit limit and only then subtracts the existing share supply:
This assumes a linear conversion where subtracting the existing position after conversion is equivalent to converting only the remaining capacity.
That assumption is false once totalAssets() != totalSupply() because ERC-4626 conversion includes virtual assets/shares and integer rounding.
Recommended Mitigation
Derive mint capacity from the remaining asset capacity:
or equivalently, after applying the same blocklist/pause/incident checks:
This calculates the maximum shares whose corresponding asset requirement fits within the vault's actual remaining deposit capacity.
Was this helpful?