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

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)      = InvalidAmount

Description

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

  1. The vault has a 200 FXRP deposit limit.

  2. An ordinary staker deposits 199.999998 FXRP, creating equal asset and share totals.

  3. maxDeposit returns two atomic FXRP, and a same-state static deposit(2) succeeds and returns two shares.

  4. An unrelated account transfers one atomic FXRP directly to the vault through the standard ERC-20 transfer function.

  5. Assets become 199.999999 FXRP while supply remains 199.999998 stXRP.

  6. maxDeposit now returns one atomic FXRP, but previewDeposit(1) returns zero shares.

  7. deposit(1) reaches the Phase 2 dust guard and reverts InvalidAmount.

  8. deposit(2) and every larger amount exceed the 200 FXRP cap and revert DepositLimitExceeded.

  9. The permissionless one-atom transfer has changed an executable advertised maximum into a positive maximum that no deposit can execute.

Normal production path

  1. An ordinary staker deposits 100 FXRP under the same 200 FXRP cap.

  2. The authorized distributor forwards an ordinary 99.999999 FXRP incentive through the scoped VaultRewardDistributor.

  3. Assets become 199.999999 FXRP while supply remains 100 stXRP.

  4. maxDeposit returns one atomic FXRP, its preview returns zero, deposit(1) reverts InvalidAmount, 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 > 0 and 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

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, maxDeposit is exactly two atomic FXRP and a static call proves deposit(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, maxDeposit is exactly one atomic FXRP and previewDeposit(1) is zero;

  • deposit(1) reverts with the target's InvalidAmount error;

  • deposit(2) reverts with the target's DepositLimitExceeded error; and

  • neither 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?