89812 sc low maxdeposit advertises positive capacity that no deposit call can execute after a direct asset transfer
Submitted on Aug 21st 2026 at 10:16:42 UTC by @legat for Audit Comp | Firelight
Report ID: #89812
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.maxDeposit returns the raw asset headroom depositLimit - totalAssets. It does not check whether depositing that headroom would mint at least one share. Once assets enter the vault without minting shares, a positive headroom can convert to zero shares. Phase 2 correctly rejects such a deposit with InvalidAmount, while every larger deposit exceeds the cap and reverts with DepositLimitExceeded.
Any underlying-token holder can force this state without a Firelight role. The PoC first proves that a two-atomic-asset maxDeposit is executable, then has an unrelated account transfer one atomic FXRP directly to the vault. That permissionless transfer alone changes maxDeposit to one atomic FXRP, makes its preview zero, and makes every positive deposit revert. No distributor, administrator, oracle, callback, or victim-selected composition is required.
The result is a state in which maxDeposit(receiver) > 0, but there is no positive asset amount up to that advertised maximum that deposit will accept. This contradicts both Firelight's stated ERC-4626 integration semantics and ERC-4626's requirement that maxDeposit not exceed the amount a same-state deposit can accept.
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 maxDeposit = 0.000002 FXRP
deposit(pre-transfer maxDeposit) = executable
unprivileged direct transfer = 0.000001 FXRP
role-free post-transfer maxDeposit = 0.000001 FXRP
previewDeposit(post-transfer maximum) = 0 shares
deposit(post-transfer maximum) = InvalidAmount
every larger positive deposit = DepositLimitExceeded
true executable positive maximum = 0
production deposit = 100.000000 FXRP
ordinary distributed incentive = 99.999999 FXRP
production-path maxDeposit = 0.000001 FXRP
production-path deposit(maxDeposit) = InvalidAmountDescription
Intended behavior
Firelight documents the vault as implementing the ERC-4626 tokenized-vault standard, emphasizing standardized accounting and predictable integration. The official maxDeposit specification requires the getter to return an amount that deposit would allow without reverting and says it must not be higher than the actual accepted maximum.
The frozen contract's own NatSpec likewise promises:
Therefore, if no positive deposit can pass all same-state vault checks, maxDeposit must return zero. ERC-4626 expressly allows the getter to underestimate when exact rounding is difficult; it does not allow an overestimate that reverts.
Actual behavior
The getter checks the receiver, pause state, incident state, and whether current assets already exceed the cap. Otherwise it returns the raw cap remainder:
deposit, however, applies one additional acceptance condition. It converts assets to shares using floor rounding, then rejects a zero-share result:
For the frozen vault's zero decimals offset, if A = totalAssets, S = totalSupply, and x is the asset amount, the conversion is:
Let H = depositLimit - A. Whenever:
maxDeposit returns H, but every amount in [1, H] converts to zero shares and reverts InvalidAmount. Every amount greater than H makes _totalAssets > depositLimit and reverts DepositLimitExceeded. The actual executable positive maximum is therefore zero.
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 one atomic direct transfer is the only state change needed to create the mismatch.
The same state class also arises through the scoped, intended production flow: VaultRewardDistributor.distributeIncentive transfers ordinary vault assets to FirelightVault and checkpoints totalAssets without minting shares. This is an independent normal-operation scenario, not a precondition for exploitation.
Root Cause
maxDeposit models only the deposit cap. It omits the zero-share acceptance predicate enforced by deposit.
This mismatch becomes permissionlessly reachable because totalAssets ultimately reads the vault's ERC-20 balance. A direct transfer grows the conversion denominator independently of share supply. The intended distributeIncentive and distributeRewards paths provide normal production reachability as well.
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-deposit query but returns a positive value that the corresponding state-changing function cannot execute. A wallet, aggregator, strategy, or user following the advertised ERC-4626 capacity receives an unusable quote and a reverting deposit. Firelight specifically promises that integrations can rely on standard vault semantics.
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 one atomic underlying-token unit:
the vault has positive supply;
assets are two atomic units below the cap in the concrete PoC, where the advertised boundary is initially executable;
any account transfers one atomic underlying asset directly to the vault;
the remaining cap headroom is positive but below the minimum asset amount that floors to one share; and
the receiver is otherwise allowed to deposit.
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 deposit(maxDeposit) succeeds immediately before that transfer and fails immediately afterward. Normal rewards and incentives independently create the same class of state in production.
Attack Path / Reproduction Path
Role-free forced path
The vault has a 200 FXRP deposit limit.
An ordinary staker deposits 199.999998 FXRP, creating equal asset and share totals.
maxDepositreturns two atomic FXRP, and a same-state staticdeposit(2)succeeds and returns two shares.An unrelated account transfers one atomic FXRP directly to the vault through the standard ERC-20
transferfunction.Assets become 199.999999 FXRP while supply remains 199.999998 stXRP.
maxDepositnow returns one atomic FXRP, butpreviewDeposit(1)returns zero shares.deposit(1)reaches the Phase 2 dust guard and revertsInvalidAmount.deposit(2)and every larger amount exceed the 200 FXRP cap and revertDepositLimitExceeded.The permissionless one-atom transfer has changed an executable advertised maximum into a positive maximum that no deposit can execute.
Normal production path
An ordinary staker deposits 100 FXRP under the same 200 FXRP cap.
The authorized distributor forwards an ordinary 99.999999 FXRP incentive through the scoped
VaultRewardDistributor.Assets become 199.999999 FXRP while supply remains 100 stXRP.
maxDepositreturns one atomic FXRP, its preview returns zero,deposit(1)revertsInvalidAmount, and every larger deposit exceeds the cap.
External Preconditions
A standard 6-decimal FXRP-compatible vault asset is used.
Any ordinary account holds one atomic unit of the underlying token and can transfer it directly to the vault.
A receiver that is not blocklisted queries and uses
maxDeposit.
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.
totalSupply > 0and an asset inflow without share minting has raised the asset-per-share ratio.0 < depositLimit - totalAssets < ceil((totalAssets + 1) / (totalSupply + 1)).
No administrator, distributor, oracle behavior, token callback, or unsafe user-selected composition is required for the forced path.
Affected Code
FirelightVault.sol#L365-L377documents and implementsmaxDepositbut returns only cap headroom.FirelightVault.sol#L422-L429derives activetotalAssetsfrom the underlying-token balance, so direct transfers change conversions without minting shares.FirelightVault.sol#L646-L671floors the conversion and rejectsshares == 0.FirelightVault.sol#L976-L989rejects any deposit whose post-operation assets exceeddepositLimit.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
Make maxDeposit apply the same conversion acceptance predicate as deposit, using one consistent snapshot of supply and assets:
Add a regression invariant over asset-skewed states:
The getter may conservatively return zero at this dust boundary, as ERC-4626 allows underestimation.
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-deposit-zero-share/firelight-vault-max-deposit-zero-share.poc.js:
Save as poc/max-deposit-zero-share/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-deposit-zero-share/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 accounting, 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,
maxDepositis exactly two atomic FXRP and a static call provesdeposit(2)succeeds;an account with no Firelight role transfers exactly one atomic FXRP directly to the vault and spends its full one-atom balance;
the transfer changes assets but not supply;
afterward,
maxDepositis exactly one atomic FXRP andpreviewDeposit(1)is zero;deposit(1)reverts with the target'sInvalidAmounterror;deposit(2)reverts with the target'sDepositLimitExceedederror; andneither failed call changes vault assets, supply, or the second staker's share balance.
The production-path test independently fails unless a real VaultRewardDistributor forwards exactly 99,999,999 atomic FXRP after a 100,000,000-atomic deposit, after which the same one-atom advertised maximum, zero preview, two exact revert reasons, and unchanged-state assertions all hold.
Was this helpful?