Vulnerability class · VC-04
Share price inflation and rounding: how vaults leak value one wei at a time
The first-depositor attack, donation attacks, rounding direction, and why 'it's only a wei' stops being true when an attacker can repeat it a million times.
Shares are a claim, and claims can be diluted
A share-based vault makes one promise: your shares represent a proportional claim on the assets. Every bug in this class is a way to break the proportion — either by inflating what one share is worth before someone else deposits, or by shaving fractions off every conversion until the accumulated dust is worth stealing.
The maths is two lines and both of them are dangerous:
shares = assets * totalSupply / totalAssets; // deposit
assets = shares * totalAssets / totalSupply; // redeem
Integer division truncates. Both totalSupply and totalAssets are attacker-influenceable in most designs. Everything below follows from those two facts.
The first-depositor inflation attack
When totalSupply is zero the formula has no ratio to use, so implementations mint 1:1. That first mint is where the attack lives.
// VULNERABLE
function deposit(uint256 assets) external returns (uint256 shares) {
shares = totalSupply == 0 ? assets : assets * totalSupply / totalAssets();
_mint(msg.sender, shares);
asset.safeTransferFrom(msg.sender, address(this), assets);
}
The sequence:
- Attacker deposits 1 wei, receiving 1 share.
totalSupply == 1. - Attacker transfers 10,000 tokens directly to the vault — not through
deposit(), just a plaintransfer.totalAssets()is now 10,000e18 + 1, buttotalSupplyis still 1. - Victim deposits 9,999 tokens. Their shares:
9999e18 * 1 / 10000e18= 0. Truncated to nothing. - Attacker redeems their single share and receives the entire vault.
The attacker's capital is at risk only for the length of one block if they front-run the victim's deposit, which is exactly what happens in practice. Note that the vulnerable ingredient is totalAssets() reading asset.balanceOf(address(this)) — that is what makes a direct transfer count.
Three fixes, ranked
1. Virtual shares and assets (best). OpenZeppelin's ERC4626 from 4.9 adds a virtual offset to both sides of the ratio, making the attack unprofitable by orders of magnitude rather than merely expensive:
function _decimalsOffset() internal view virtual returns (uint8) {
return 6; // shares are minted 10^6 : 1 against assets
}
// convertToShares uses (totalSupply + 10**offset) and (totalAssets + 1)
2. Dead shares on first deposit. Mint a minimum quantity to address(0) the first time, so totalSupply can never be 1:
if (totalSupply == 0) {
require(assets > MINIMUM_LIQUIDITY, "initial deposit too small");
_mint(address(0), MINIMUM_LIQUIDITY); // permanently locked
shares = assets - MINIMUM_LIQUIDITY;
}
3. Internal accounting instead of balanceOf. Track deposited assets in a storage variable so a direct transfer does not change totalAssets() at all. This closes donation attacks generally, not just the first-deposit case — but it means genuine yield must be reported explicitly, which is a design change rather than a patch.
Rounding direction: the invariant nobody writes down
Every conversion must round in the direction that favours the protocol. Stated precisely:
deposit(assets in, shares out) → round shares down.mint(shares out, assets in) → round assets up.withdraw(assets out, shares in) → round shares up.redeem(shares in, assets out) → round assets down.
Get one of these backwards and every operation leaks a fraction of a unit to the user. That sounds harmless until you notice the attacker can call it in a loop.
// VULNERABLE: rounds in the user's favour on withdraw
function withdraw(uint256 assets) external returns (uint256 shares) {
shares = assets * totalSupply / totalAssets(); // rounds DOWN
_burn(msg.sender, shares); // burns too few
asset.safeTransfer(msg.sender, assets);
}
// FIXED
function withdraw(uint256 assets) external returns (uint256 shares) {
shares = _mulDivUp(assets, totalSupply, totalAssets()); // rounds UP
_burn(msg.sender, shares);
asset.safeTransfer(msg.sender, assets);
}
The economics: if each withdrawal leaks 1 wei and gas costs 30,000 units, the attack is not profitable on Ethereum mainnet. On an L2 where a transaction costs a fraction of a cent, and where a single transaction can batch a thousand iterations, it becomes profitable at surprisingly small leak sizes. Always evaluate rounding leaks against the gas costs of the target chain.
Precision loss beyond rounding direction
Division before multiplication. The most common precision bug in the class, and it survives code review because it looks like a gas optimisation:
// WRONG: truncates before the multiply
uint256 interest = (debt * rate) / 1e18 * elapsed / 365 days;
// RIGHT: all multiplications first
uint256 interest = (debt * rate * elapsed) / (1e18 * 365 days);
At small debt the first version truncates to zero and interest stops accruing entirely — a free loan below a threshold. Tests pass because they use realistic magnitudes.
Decimal mismatches. USDC has 6 decimals, WBTC has 8, most ERC-20s have 18, and Chainlink feeds have 8. Any maths mixing them without explicit normalisation is wrong somewhere. Test every accounting path with a 6-decimal token; it is the single highest-yield test in this class.
Non-standard token behaviour. Fee-on-transfer tokens mean the vault receives less than assets. Rebasing tokens mean balanceOf changes without a transfer. Both silently break share accounting. Either measure the balance delta:
uint256 before = asset.balanceOf(address(this));
asset.safeTransferFrom(msg.sender, address(this), assets);
uint256 received = asset.balanceOf(address(this)) - before;
shares = convertToShares(received); // mint against what actually arrived
— or document and enforce an allowlist of supported tokens. What you cannot do is assume standard behaviour and let the owner whitelist arbitrary assets later.
The invariants that catch all of this
This entire class is what invariant testing was built for. The properties are short, and a fuzzer finds violations that reading will not.
// No user may extract more than they put in, absent real yield.
function invariant_noValueCreation() public view {
assertGe(handler.ghost_depositSum() + ghost_yield,
handler.ghost_withdrawSum());
}
// Share price never decreases outside a realised loss.
function invariant_sharePriceMonotonic() public view {
assertGe(vault.convertToAssets(1e18), ghost_lastSharePrice);
}
// Round-tripping must never be profitable.
function invariant_roundTripNotProfitable(uint256 assets) public {
assets = bound(assets, 1, 1e24);
uint256 shares = vault.previewDeposit(assets);
assertLe(vault.previewRedeem(shares), assets);
}
// The vault can always honour every outstanding share.
function invariant_solvent() public view {
assertGe(vault.totalAssets(), vault.convertToAssets(vault.totalSupply()));
}
The last one is the one to write first. If it holds under a few million fuzzed sequences including donations, dust deposits and adversarial ordering, most of this article is already covered.
Review checklist
- Check the
totalSupply == 0branch. Virtual offset, dead shares or internal accounting — one of the three must be present. - Does
totalAssets()readbalanceOf? If so, a direct transfer changes the share price. Confirm that is intended. - Verify rounding direction on all four ERC-4626 entry points, then price a round-trip leak against the target chain's gas cost.
- Grep for division that precedes multiplication in any accrual or conversion path.
- Test every accounting path with 6-, 8- and 18-decimal tokens.
- Test with a fee-on-transfer mock and a rebasing mock, even if you believe they are unsupported.
- Write the four invariants above and run them for a few million sequences before shipping.
Value leaks in this class do not announce themselves. There is no dramatic drain transaction to point at — just a share price that is slightly wrong, forever, in a direction that is not yours.
All code in this article is illustrative — written to demonstrate a pattern, not taken from any client engagement. If you want this class of issue hunted in your own codebase, that is what a Invariant & Fuzz Testing is for.