Background
Quillon Vaults pools a single ERC-20 asset into ERC-4626 vaults and allocates the capital to yield strategies. Depositors receive shares; the number minted is the deposit multiplied by the share supply and divided by the vault’s total assets, rounded down.
The July 2026 review covered the vault core ahead of a release that deploys a new vault for each strategy family. New vaults start empty, and the release plan opened each one to the public in the same transaction that created it. An empty, publicly callable vault is the precondition for every share-inflation attack.
The issue was reported as finding QV-01 of the research assessment, alongside a related rounding issue in previewWithdraw (QV-05).
Architecture
Deposits and withdrawals go through QuillonVault, which owns share accounting. totalAssets() adds the idle balance to the value StrategyManager reports for deployed capital, so any token sent straight to the vault raises totalAssets() without minting a share.
The diagram highlights the vault’s empty state. The strategy valuation path is shown because it is the second way totalAssets() can move outside a deposit (QV-02).
Figure 1Quillon Vaults: components and paths involved in this case
Elements:
- Depositors (external party): deposit / redeem
- First depositor (adversary): deposits 1 unit, donates
- QuillonVault (protocol component, involved in this case): ERC-4626 vault: deposits, withdrawals and share accounting
- StrategyManager (protocol component): Allocates capital, harvests yield and routes fees
- AmmLpStrategy (protocol component): Provides liquidity to an AMM pool and values the position
- WithdrawalQueue (protocol component): Queues withdrawals from illiquid strategies
- QuillonTimelock (protocol component): Delays parameter changes and upgrades
Connections:
- Depositors to QuillonVault: deposit() (data or call flow)
- First depositor to QuillonVault: transfer() to vault (attack path)
- QuillonVault to StrategyManager: totalAssets() (data or call flow)
- StrategyManager to AmmLpStrategy: valuation (data or call flow)
- QuillonVault to WithdrawalQueue: queued exits (data or call flow)
- QuillonTimelock to StrategyManager: parameters (control path)
Components in the Quillon Vaults profile
QuillonVault(involved in this case)
ERC-4626 vault: deposits, withdrawals and share accounting
StrategyManager
Allocates capital, harvests yield and routes fees
AmmLpStrategy
Provides liquidity to an AMM pool and values the position
WithdrawalQueue
Queues withdrawals from illiquid strategies
QuillonTimelock
Delays parameter changes and upgrades
Security challenge
ERC-4626 converts between assets and shares with integer division, and the standard requires deposits to round in the vault’s favour. When the supply is tiny and total assets are large, the rounding is no longer negligible: a deposit worth less than one share mints nothing.
The review had to establish whether that state was reachable on Quillon’s release path, what it would cost an attacker, and which fix removes the incentive rather than only raising the price of the attack.
Attack surface
Every input to the share conversion was examined for ways to move it outside a normal deposit or withdrawal.
| Surface | Exposure | Control |
|---|---|---|
totalAssets() idle balance | Rises on any direct token transfer to the vault | None at the reviewed commit |
| Strategy valuation | LP position valued from pool spot reserves (QV-02) | 30-minute TWAP with a 2% deviation guard after remediation |
| Share supply at launch | Zero until the first deposit | Virtual offset of 10^6; zero-share revert |
previewWithdraw rounding | Rounded down, in the user’s favour (QV-05) | Rounds up on withdraw and mint paths |
| Transaction ordering | An attacker can front-run the first real deposit | Not relied on; the arithmetic fix makes ordering irrelevant |
Technical investigation
Reviewers reproduced the attack in a Foundry test against the reviewed commit. The attacker deposits one base unit and receives one share, then transfers 100,001 units directly to the vault. totalAssets() now includes the donation while the supply is still one share.
The next depositor sends 100,000 units and receives 100,000e6 × 1 / (100,001e6 + 1), which rounds down to zero shares. The deposit succeeds, and the attacker’s single share now redeems the donation, the original unit and the victim’s 100,000 units.
The first table is computed from the conversion formula. It compares the reviewed implementation with two virtual-offset settings for the same donation and deposit. With an offset of 10^6 the victim receives shares worth their deposit and the attacker forfeits half of the donation, so the attack no longer pays.
An invariant fuzzing campaign with the property “a deposit that mints zero shares reverts” found the same counterexample independently. The property is now INV-03 in the protocol’s invariant suite.
Figure 2Exploit sequence against an empty vault
- Attacker (adversary) to QuillonVault: deposit(1). mints 1 share (attack step)
- Attacker (adversary) to Deposit asset: transfer(vault, 100,001). no shares minted (attack step)
- Depositor (participant) to QuillonVault: deposit(100,000). shares = 0 after rounding (normal step)
- QuillonVault (on-chain) to Deposit asset: transferFrom(victim, vault, 100,000) (normal step)
- Attacker (adversary) to QuillonVault: redeem(1). whole balance (attack step)
- QuillonVault (on-chain) to Attacker: assets out (attack step)
| Conversion | Victim shares | Victim redeemable (units) | Victim loss | Attacker result (units) |
|---|---|---|---|---|
| No offset (reviewed commit) | 0 | 0.00 | 100.00% | +100,000.00 |
| Virtual offset 10^3 | 1,999 | 99,975.49 | 0.02% | −49,988.24 |
| Virtual offset 10^6 | 1,999,980 | 99,999.99 | 0.00% | −50,000.49 |
Timeline
Assessment begins on the reviewed commit
Detection
QV-01 reproduced in a Foundry test and reported privately
Containment
Deployment of new vaults put on hold
Fix
Remediation commit with virtual offset and zero-share revert
Verification
Fix verified; INV-03 holds under fuzzing
Assessment report published
Root cause
Share conversion used the raw supply and asset balance with no offset, and deposit accepted a result of zero shares. The design assumed the first deposit would be large and honest; nothing in the code enforced that.
Two smaller decisions widened the window: assets sent directly to the vault counted toward totalAssets() immediately, and the release script opened each vault in the transaction that deployed it.
- Class
- First-depositor attack
- Severity
- Critical
- Components involved
- QuillonVault
function _convertToShares(uint256 assets, Math.Rounding r) internal view returns (uint256) { uint256 supply = totalSupply(); return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), r); // no virtual offset} function deposit(uint256 assets, address receiver) public override returns (uint256 shares) { shares = previewDeposit(assets); _deposit(msg.sender, receiver, assets, shares); // shares may be zero: the deposit still succeeds}Risk evaluation
At the start of the assessment all findings were open, including QV-01 (critical) and QV-02 (high). The model penalises Code Security for each open finding; Economic Security is reduced while strategy valuation relied on spot reserves.
The after state is the protocol’s current profile in the Quantix Risk Index.
The score moves from 67.8 (B) to 82.5 (A), a change of +14.7 points. The largest single contribution is Code Security: restoring it from 39.0 to 84.0 alone moves the score from 67.8 to 81.3.
| Component | Weight | Range | During case | Today | Score points |
|---|---|---|---|---|---|
| Code Security | 30% | 39.0 | 84.0 | +13.5 | |
| Operational Security | 20% | 80.0 | 80.0 | 0.0 | |
| Governance Risk | 15% | 78.0 | 78.0 | 0.0 | |
| Economic Security | 15% | 74.0 | 82.0 | +1.2 | |
| Infrastructure Resilience | 10% | 85.0 | 85.0 | 0.0 | |
| Transparency | 10% | 88.0 | 88.0 | 0.0 | |
| Quantix Risk Index | 100% | 67.8 | 82.5 | +14.7 |
Scores use the six weighted components of the Quantix Risk Index. Open findings reduce Code Security by a fixed penalty per severity; the current profile is the protocol’s entry in Quantix Intelligence.
Mitigation strategy
The vault now adds a virtual offset to both sides of the conversion, as in the OpenZeppelin ERC-4626 implementation, and reverts when a deposit or mint would produce zero shares. With an offset of 10^6 an attacker must donate a million times more to cause the same rounding, and most of the donation is captured by the virtual shares.
The release changed as well: each new vault is seeded from the treasury before it opens, and previewWithdraw and previewMint now round up as the standard requires (QV-05).
- 01Add
_decimalsOffset() = 6and use the offset formula for both conversions - 02Revert deposits and mints that produce zero shares
- 03Round up in
previewWithdrawandpreviewMint - 04Seed every new vault from the treasury before opening it
- 05Keep the counterexample as invariant INV-03 in CI
function _convertToShares(uint256 assets, Math.Rounding r) internal view returns (uint256) {- uint256 supply = totalSupply();- return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), r);+ return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, r); }++function _decimalsOffset() internal pure override returns (uint8) {+ return 6;+} function deposit(uint256 assets, address receiver) public override returns (uint256 shares) { shares = previewDeposit(assets);+ if (shares == 0) revert ZeroShares(); _deposit(msg.sender, receiver, assets, shares); }Security outcome
QV-01 was fixed and verified in the remediation commit before any new vault opened. The invariant that failed during the review holds under fuzzing, and the seeded launch is part of the release checklist.
Together with the QV-02 fix, both ways totalAssets() could be moved by someone who is not depositing are closed.
- Detection to containment
- 45 min
- Detection to first fix
- 11.8 days
- Detection to verified fix
- 14.1 days
- Risk Index change
- B → A (+14.7)
Lessons learned
- 01
An empty vault is a distinct state with its own threat model; review it explicitly.
- 02
Rounding direction is a security property. A zero-share deposit should never succeed silently.
- 03
Prefer fixes that remove the attacker’s profit, such as virtual offsets, over fixes that only raise the cost.
- 04
Turn every validated exploit into a permanent invariant test.

