89813 sc low maxmint overstates the executable cap after a direct asset transfer changes the asset to share ratio
Submitted on Aug 21st 2026 at 10:23:52 UTC by @legat for Audit Comp | Firelight
Report ID: #89813
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 converts the entire asset cap to shares with floor rounding, then subtracts existing share supply. mint, by contrast, converts only the newly requested shares to assets with ceiling rounding and rejects the operation if those assets exceed the remaining cap headroom. Those are not equivalent calculations once assets enter the vault without minting shares.
Any underlying-token holder can force the mismatch without a Firelight role. The PoC first proves that maxMint = 100 stXRP is executable in a one-to-one state, then has an unrelated account transfer 0.007071 FXRP directly to the vault. That permissionless transfer alone changes maxMint to 99.985859 stXRP, one atomic share above the actual maximum. Calling mint(maxMint(receiver), receiver) then needs one atomic asset more than the remaining cap and reverts DepositLimitExceeded, while minting exactly one atomic share less succeeds from the same state.
No distributor, administrator, oracle, callback, or victim-selected composition is required. The runnable PoC deploys the exact frozen contracts and proves both the role-free transition and the normal production reward path:
role-free pre-transfer maxMint = 100.000000 stXRP
mint(pre-transfer maxMint) = executable
unprivileged direct transfer = 0.007071 FXRP
role-free post-transfer maxMint = 99.985859 stXRP
remaining asset capacity = 99.992929 FXRP
previewMint(post-transfer maxMint) = 99.992930 FXRP
mint(post-transfer maxMint) = DepositLimitExceeded
true executable maximum = 99.985858 stXRP
mint(true executable maximum) = success
ordinary distributed incentive = 0.007071 FXRP
production-path maxMint = 99.985859 stXRP
production-path mint(maxMint) = DepositLimitExceededAlthough the arithmetic overstatement is one atomic share, it makes the entire advertised boundary operation—approximately 99.985859 stXRP in the reproduction—unexecutable.
Description
Intended behavior
Firelight documents the vault as implementing the ERC-4626 tokenized-vault standard, emphasizing standardized accounting and predictable integration. The official maxMint specification requires the getter to return an amount that mint would allow without reverting and says it must not be higher than the actual accepted maximum.
The frozen contract's own NatSpec likewise promises:
The maximum must therefore be derived from the assets still available under the cap and the same conversion relation used by mint.
Actual behavior
The getter converts the total depositLimit at the current exchange rate, then subtracts current supply:
mint instead ceiling-converts only the newly requested shares to assets, then adds those assets to current assets before checking the cap:
For the frozen vault's zero decimals offset, let:
The getter returns:
The actual maximum that satisfies mint's ceiling conversion and cap check is:
The getter's “convert the total cap, then subtract supply” expression is not distributive through integer floor rounding and the ERC-4626 virtual-asset/share terms. After an asset inflow increases A without increasing S, M can equal T + 1. previewMint(M) then ceiling-rounds to H + 1 assets and _depositFunds reverts, while previewMint(T) <= H and the mint succeeds.
totalAssets() is derived from the vault's underlying-token balance, less pending withdrawals. A standard ERC-20 transfer to the vault therefore increases A without minting shares and requires no Firelight function or role. The role-free PoC starts from an executable boundary and shows that a direct transfer of 7,071 atomic FXRP is the only state change needed to create the exact overstatement.
The same state also arises through VaultRewardDistributor.distributeIncentive, an intended scoped production path that transfers assets into the vault without minting new shares. That scenario confirms ordinary protocol reachability but is not a precondition for the role-free forced path.
Root Cause
maxMint converts the full absolute asset cap and subtracts supply. The executable limit must instead convert the remaining asset headroom depositLimit - totalAssets using the same snapshotted totals.
The incorrect expression appears correct only in the one-to-one states covered by the historical regression tests. Because totalAssets ultimately reads the vault's ERC-20 balance, any holder can change the ratio through a direct transfer. Ordinary distributeIncentive or distributeRewards independently exposes the same error during normal production operation.
Risk
Impact
This is Smart Contract Low, the listed impact “Contract fails to deliver promised returns, but doesn't lose value.”
The contract promises a standards-compatible maximum-mint query but advertises a share amount its corresponding state-changing function rejects. Wallets, aggregators, strategies, and users conventionally use maxMint to size a boundary operation. Here the approximately 99.985859 stXRP operation returned by the contract reverts, even though the immediately adjacent true maximum succeeds.
This mapping is also consistent with Firelight's published competition result #58993, where an incorrect FirelightVault view return was accepted under the same listed Low impact.
Likelihood
The role-free path requires only ordinary vault state and ownership of 0.007071 FXRP:
the vault has positive supply and assets below the deposit cap;
maxMintis initially executable in the concrete one-to-one state;any account transfers 7,071 atomic underlying units directly to the vault, making
totalAssets != totalSupply;the integer boundary falls on a state where the full-cap formula rounds one share atom above the remaining-headroom formula; and
the receiver is otherwise allowed to mint.
The attacker calls only the underlying ERC-20 transfer; the vault cannot require a role or reject the inbound transfer. The PoC proves causality by checking that mint(maxMint) succeeds immediately before that transfer and fails immediately afterward. Normal rewards and incentives independently create the same state in production. The arithmetic condition is deterministic and can recur whenever any asset inflow changes the asset/share ratio or the cap changes relative to that ratio.
Attack Path / Reproduction Path
Role-free forced path
The vault has a 200 FXRP deposit limit.
An ordinary staker deposits 100 FXRP and receives 100 stXRP.
maxMintreturns 100 stXRP, and a same-state staticmint(100 stXRP)succeeds and requires 100 FXRP.An unrelated account transfers 0.007071 FXRP directly to the vault through the standard ERC-20
transferfunction.Vault assets become 100.007071 FXRP while supply remains 100 stXRP, leaving 99.992929 FXRP of cap headroom.
maxMintnow returns 99.985859 stXRP.previewMint(99.985859 stXRP)returns 99.992930 FXRP, one atomic FXRP more than the remaining cap.mint(maxMint, receiver)revertsDepositLimitExceeded.From the unchanged state,
previewMint(99.985858 stXRP)returns 99.992928 FXRP andmint(99.985858 stXRP)succeeds.
Normal production path
Start from the same 100 FXRP deposit and 200 FXRP cap.
The authorized distributor forwards an ordinary 0.007071 FXRP incentive through the scoped
VaultRewardDistributor.The resulting assets, supply, remaining capacity, advertised maximum, required assets, revert, and true successful maximum are identical to the role-free post-transfer state.
External Preconditions
A standard 6-decimal FXRP-compatible vault asset is used.
Any ordinary account holds 0.007071 FXRP and can transfer it directly to the vault.
A receiver that is not blocklisted queries and uses
maxMint.
The authorized distributor is not an external precondition. Its normal incentive path is retained only as independent production reachability.
Internal Preconditions
The vault is not paused and has no active incident.
totalAssets < depositLimit.The asset-skewed totals make the full-cap floor expression one share atom greater than the remaining-headroom expression.
No administrator, distributor, oracle behavior, token callback, or unsafe user-selected composition is required for the forced path.
Affected Code
FirelightVault.sol#L380-L392documents and implementsmaxMintusingconvertToShares(depositLimit) - totalSupply.FirelightVault.sol#L422-L429derives activetotalAssetsfrom the underlying-token balance, so direct transfers change conversions without minting shares.FirelightVault.sol#L682-L704ceiling-converts the newly requested shares to assets.FirelightVault.sol#L976-L989adds those assets to existing assets and enforcesdepositLimit.FirelightVault.sol#L1030-L1073defines the snapshot conversion formulas and rounding.VaultRewardDistributor.sol#L163-L176supplies the exact ordinary incentive path used by the PoC.
Recommended Mitigation
Calculate the maximum from the remaining asset capacity using one consistent snapshot:
Add asset-skewed regression invariants:
Proof of Concept
The program requires a runnable PoC. This report is standalone: the complete test and guarded runner are embedded below. The test uses the exact frozen commit and the repository's normal deployment fixture. That fixture deploys the real frozen FirelightVault proxy, the repository-bundled FAsset implementation behind its real FAssetProxy, and—only for the production scenario—the real frozen VaultRewardDistributor. MockContract is used solely as the local AssetManager authorization/minting shim; it does not replace FXRP transfer, balance, approval, or vault behavior. No vault, reward, conversion, cap, checkpoint, or revert behavior is mocked or replaced.
Complete PoC source
Save as poc/max-mint-cap-rounding/firelight-vault-max-mint-cap-rounding.poc.js:
Save as poc/max-mint-cap-rounding/run.sh:
The exact embedded files have these SHA-256 hashes:
Standalone clean reproduction
Requirements: Git, Node.js, and npm. No .env file is required for local tests.
Save the two complete code blocks above at these exact repository-relative paths:
Then run:
The runner rejects the wrong Git commit or any modification to the vulnerable vault, production distributor, bundled FAsset implementation, or bundled FAsset proxy. It then runs only the two targeted tests on Hardhat's local chain and preserves the complete console output at poc/max-mint-cap-rounding/evidence/local-e2e.log.
Captured output
Determinism
Verified environment: Node.js v24.18.0, npm 11.16.0, Hardhat 2.22.19. The test ran against commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62; the runner pins the decisive contract hashes shown above. The claimed behavior consists entirely of deterministic EVM state transitions in the frozen contracts: ERC-20 transfer, ERC-4626 conversion rounding, cap checks, and exact custom-error reverts. It does not claim any network, consensus, validator, RPC, mempool, or cross-node behavior. Hardhat's in-process chain therefore supplies the complete execution environment; Docker, a public fork, and a multi-node cluster are neither required nor probative for this finding.
The role-free test fails unless all of the following hold:
before the unrelated transfer,
maxMintis exactly 100,000,000 atomic stXRP and a static call provesmint(maxMint)succeeds;an account with no Firelight role transfers exactly 7,071 atomic FXRP directly to the vault and spends its full transferred balance;
the transfer changes assets but not supply;
afterward, remaining cap capacity is exactly 99,992,929 atomic FXRP and
maxMintis exactly 99,985,859 atomic stXRP;previewMint(maxMint)requires 99,992,930 atomic FXRP;mint(maxMint)reverts with the target'sDepositLimitExceedederror and changes no state;the independently derived true maximum is exactly 99,985,858 atomic stXRP; and
mint(trueMaximum)succeeds and emits the exactDepositevent.
The production-path test independently fails unless a real VaultRewardDistributor forwards exactly 7,071 atomic FXRP after a 100,000,000-atomic deposit, after which the same post-transfer totals, advertised maximum, required assets, exact revert, true maximum, and successful mint assertions all hold.
Was this helpful?