For the complete documentation index, see llms.txt. This page is also available as Markdown.

88596 sc low firelightvault maxmint returns a share count that mint rejects and maxdeposit an asset amount that deposit rejects

Submitted on Aug 14th 2026 at 02:18:06 UTC by @whitehorse for Audit Comp | Firelight

  • Report ID: #88596

  • 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

maxMint sizes the remaining deposit budget in converted share units and subtracts raw totalSupply(), while mint prices the same quantity back to assets with ceiling rounding and charges it against the same depositLimit. The two legs round in opposite directions, so mint(maxMint(r), r) reverts DepositLimitExceeded, which is exactly the outcome ERC4626 requires maxMint to prevent. Any unprivileged address forces that state on demand for a net cost of one base unit of FXRP, and it then denies the standard mint(maxMint(self), self) idiom to every caller until the next write of totalSupply() or totalAssets(). The sibling function fails the same way from a different cause: deposit(maxDeposit(r), r) reverts InvalidAmount once the remaining headroom falls below the share price.

Vulnerability Details

Write L = depositLimit, S = totalSupply(), A = totalAssets().

Leg 1, maxMint at FirelightVault.sol:385 to :393.

uint256 shares = totalSupply();                          // :386  raw share units
uint256 sharesLimit = convertToShares(depositLimit);     // :387  assets to shares, Floor
...
return sharesLimit - shares;                             // :391  converted minus raw

convertToShares is not overridden, so :387 floors, and _decimalsOffset is never defined, so the cushion is 1. The correct raw minus raw shape sits seven lines above at maxDeposit:372 and :376.

mint never calls super.mint, so OpenZeppelin's own maxMint precheck is overridden away. It converts with Math.Rounding.Ceil at :698 through _convertToAssetsTotals:1073, and _depositFunds enforces the budget:

So maxMint() returns floor(L * (S + 1) / (A + 1)) - S and mint charges ceil(m * (A + 1) / (S + 1)). Comparing that against the correctly based convertToShares(L - A) gives an exact trigger and an exact bound, with r = (L * (S + 1)) mod (A + 1):

The overstatement is one share unit at any share price, and the resulting overshoot past depositLimit is one asset base unit. :987 compares with a strict >, so one unit is enough.

At Flare block 67,334,624 the closed form reproduces the live maxMint() return of 2,802,893,946,606 to the unit, and it does so at ten further blocks spanning 55 hours, which confirms the mixed basis and the cushion on the deployed artifact. The trigger is not satisfied at rest at any of those states: r is 56,644,815,406,212 against A - S of 4,126,715,962, an incidence of 7.1938e-5.

It is forced by choosing the size of an ordinary state write. totalAssets():428 is the raw ERC20 balance minus pendingWithdrawAssets, so a deposit of 814 base units at that block moves the state into the trigger. The attacker keeps 813 shares that redeem for 813 assets, so the action costs one base unit of FXRP plus gas. Solved at eleven live states the amount ranges 213 to 13,442 base units and the net cost is one base unit at every one. A bare FXRP.transfer(vault, d) also works, at 379 to 22,118 base units, and runs no vault code at all since the vault declares neither receive nor fallback.

The forced state persists until any writer of S or A executes. Enumerating every such write over blocks 67,161,248 to 67,334,048 gives 97 writes in 55.04 hours, a median gap of 1,152 blocks, which is 22 minutes.

Leg 2, maxDeposit at :371 to :378. It returns depositLimit - totalAssets() with no floor to zero guard, while deposit rejects a deposit whose share count floors to zero:

so deposit(maxDeposit(r), r) reverts whenever 0 < L - A < (A + 1) / (S + 1). At the live share price of 1.000071943 that band is the single value L - A == 1; at a price of 3 it is L - A in {1, 2}. The true maximum deposit accepts in that band is zero. The guard at :669 is the fix for 0xMacro finding L-1, "prevent zero-share deposits", commit 6806f35, and maxDeposit was not brought along with it.

Fix. Compute each budget in the basis its entry point charges, and apply the same floor to zero rule:

convertToShares floors, so the share count prices back to at most the headroom under the ceiling rounding mint uses. Both changes are needed: fixing maxMint alone leaves leg 2 standing, and the proof of concept demonstrates that in its isolation arm.

maxWithdraw, maxRedeem and the four inherited preview functions were exercised against their entry points across the same scan and are consistent.

Impact Details

While the forced state holds, the vault publishes a maximum mint size through its own ERC4626 view and then rejects a mint of exactly that size. vault.mint(vault.maxMint(self), self) composed in one call frame, the idiom the standard exists to make safe, reverts. The state is not caller specific and needs no front running: it sits in storage for a median of 22 minutes at the measured write rate and applies to every caller of that idiom for as long as it lasts. Arming it again costs one more base unit of FXRP. The same holds for deposit(maxDeposit(self), self) inside the headroom band, which exists only when the vault is at its cap, the state in which a caller has a reason to size a deposit off the view at all.

No funds move. mint reverts atomically before _deposit runs and deposit reverts before assets are pulled. A caller who does not trust the views can pass maxMint() - 1, and the proof of concept asserts that this succeeds at the same forced state. The attacker gains nothing: one base unit of FXRP worse plus gas on the deposit route, the whole amount given away irrecoverably on the transfer route, and nothing extracted from the vault or from any holder on either.

Leg 2 is 2,803,095,595,672 base units of headroom away from its condition at block 67,334,624, so it is carried for the completeness of the fix rather than as an independently reachable claim today. Leg 1 is reachable now.

The vault proxy 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3 currently runs the Phase 1 predeposit implementation 0x70ccF1bEe0C1217069fE74083Ca71Af7bcd7Fb76 at contractVersion() 1, which carries the identical maxMint body at contracts/legacy/FirelightVaultPredeposits.sol:340 to :348. That is why the live reads above match the closed form. The in scope asset is the source at tag v1_audit_ready, commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62, and the proof of concept runs against that source.

The defect is the incorrect return value of two standard mandated functions rather than a loss of value, which is the in scope impact claimed: "Contract fails to deliver promised returns, but doesn't lose value."

References

  • contracts/core/FirelightVault.sol:385 to :393, maxMint, and :387, :391, the mixed basis

  • contracts/core/FirelightVault.sol:371 to :378, maxDeposit, and :376, the unguarded headroom

  • contracts/core/FirelightVault.sol:665, :669, the floor to zero conversion and the guard that rejects it

  • contracts/core/FirelightVault.sol:698, :985, :987, the ceiling conversion and the budget enforcement

  • contracts/core/FirelightVault.sol:1064, :1073, the two conversion helpers and the undefined _decimalsOffset

  • contracts/core/FirelightVault.sol:427 to :429, totalAssets as the raw balance minus pendingWithdrawAssets

  • contracts/core/interfaces/IFirelightVault.sol:14 to :18, the enumerated ERC4626 deviations, which cover the delayed withdraw and redeem semantics and the suppressed Withdraw event and neither of these

  • contracts/legacy/FirelightVaultPredeposits.sol:340 to :348, the identical body in the deployed implementation

  • EIP-4626: maxMint "MUST NOT be higher than the actual maximum that would be accepted"; maxDeposit "MUST return the maximum amount of assets deposit would allow to be deposited for receiver and not cause a revert"

  • 0xMacro Firelight A-1, finding L-1, "prevent zero-share deposits", commit 6806f35

Proof of Concept

Was this helpful?