First-depositor donation can round the next mint to zero shares. Local toy vault plus OpenZeppelin virtual-offset discussion.
Learn how to install Kali Linux dual boot alongside Windows 10/11. Complete step-by-step guide with partitioning, GRUB setup, troubleshooting, and safety warnings to prevent data loss.
13 min read
Transparent vs UUPS, uninitialized implementations, and storage-layout collisions — local forge inspect lab. No live upgrades.
35 min read
ERC-4626 share inflation is an exchange-rate bug: shares round down, and a direct token transfer can move that rate before the next depositor arrives. This Andrax tutorial — by Syed Zada Abrar — covers ~55% theory, ~45% local vault lab.
Prerequisites: DeFi Flash Loan Attacks Explained; Oracle Manipulation in Smart Contracts; Foundry + Slither Setup.
Also useful: Solidity Reentrancy Explained; Hub; How to Start Web3 Bug Hunting in 2026.
Ethics fence: the vault below is an ILLUSTRATIVE / local toy. Run it in Foundry. Do not donate into a live vault, front-run a mainnet deposit, or “confirm” inflation on a deployed ERC-4626. Immunefi rules prohibit mainnet and public-testnet testing of in-scope code.
ERC-4626 is an ERC-20 whose tokens are shares of one underlying asset. deposit / mint pull assets and issue shares. withdraw / redeem burn shares and send assets. totalAssets is whatever the vault says it manages. convertToShares and convertToAssets round down and must not include fees. The preview* functions are closer to the real exchange and can include fees; the security considerations say they are manipulable by changing on-chain conditions and are not always safe to use as price oracles. The convert* functions are allowed to be inexact so they can be built as robust oracles (for example with a TWAP). If a lending market reads previewRedeem as a spot price, you are back in oracle manipulation — the vault standard does not make that read safe.
Rounding direction is part of the spec: when the vault issues shares for assets, or pays assets for shares, it should round toward the vault. When it quotes assets the user must supply, or shares the user must burn, it should round toward charging the user more. convertTo* always round down so integrators see a consistent direction.
Empty-vault math in a naive implementation often looks like:
shares = supply == 0 ? assets : assets * supply / totalAssets
totalAssets that returns asset.balanceOf(address(this)) goes up when anyone transfers tokens to the vault, not only when they deposit. That transfer is the donation. It does not mint shares.
OpenZeppelin’s ERC-4626 guide draws the rate as assets versus shares and shows why rounding becomes theft when the rate is steep (Security concern: inflation attack). Deposits round toward zero shares. If you are due less than one share, you receive zero and the assets stay in the vault — a donation to existing shareholders.
On an empty vault the sequence is:
deposits a tiny amount a0 (1 wei in the usual telling) and receives a0 shares when supply was zero.a1 assets directly to the vault. Supply is unchanged. totalAssets is now about a0 + a1.u. Shares minted are u * a0 / (a0 + a1), rounded down.< 1, the user gets 0 shares and the attacker, as the only shareholder, can redeem the user’s assets.OZ’s inequality: with a0 = 1, choosing a1 = u is enough, so the attacker commits about u + 1 to try to take a deposit of size u. The donation is not free profit against a random large vault. It is sized to the victim’s deposit, which is why the practical shape is a first depositor (or any moment supply is still tiny), often front-running the first honest deposit in the same block. Capital for a1 can be a flash loan — that is an amplifier, covered in DeFi Flash Loan Attacks Explained. The root cause is the share formula plus a balance-based totalAssets, not the loan.
A public write-up of the same direct-donation rounding is James Wright, “ERC-4626 Share Inflation Attacks” (15 January 2025) on securitymath.com (post). Use it as a second narration of pattern 1 only. This tutorial does not adopt that page’s TVL or audit-count figures, and it does not teach the page’s “donate, then withdraw your own shares at an inflated rate, to profit from existing depositors” sequence — donating and then redeeming your own shares gives the donation to whoever already held shares. The profitable order is: inflate, let the victim deposit into the skewed rate, then redeem.
OZ’s defense, following YieldBox, is a virtual offset inside the conversion, not a blacklist of transfer:
shares = assets * (totalSupply + 10**decimalsOffset) / (totalAssets + 1)
(with the matching inverse for assets, and the rounding direction ERC-4626 already requires). Two ideas stack:
+ 1 asset, and 10**offset shares).The guide’s conclusion, which this tutorial does not re-derive: even with decimalsOffset == 0, the attacker no longer owns every virtual share, so part of the donation is captured and a full dilution of the user is not profitable (the attacker’s loss is at least on the order of the user’s deposit). A larger offset makes that loss grow by powers of ten. _decimalsOffset() is the hook; leaving it at zero still keeps the + 1 virtual asset and the + 1 virtual share (10**0).
That is “virtual shares OpenZeppelin,” not “inflation is impossible.” A custom totalAssets that ignores the offset, a fee override that breaks previewDeposit vs deposit, or a strategy that reports a manipulable NAV can put the rate back under the attacker’s control. Internal balance accounting (count assets only inside deposit / withdraw, ignore raw balanceOf) also stops direct donations, at the cost of tracking every other inflow yourself. A minimum first deposit raises the cost of the empty-vault case; it is not a substitute for the offset once you care about the OZ profitability bound.
ERC-4626 also notes that fully permissionless integrators can hit a contract that matches the interface and not the spec. Read the implementation. And if the vault has callbacks (ERC-777 assets, hooks on deposit), read them as reentrancy — this guide does not build that lab.
Goal: on a local toy, show a victim mint of 0 shares after a donation, then show the virtual-offset formula mints a non-zero amount for the same numbers. No mainnet vault. No flash-loan harness.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// ILLUSTRATIVE / LOCAL LAB ONLY — not ERC-4626 complete, not production
interface IERC20Mini {
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
function transferFrom(address, address, uint256) external returns (bool);
}
contract ToyInflationVault {
IERC20Mini public immutable asset;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
constructor(IERC20Mini a) {
asset = a;
}
/// Naive: 1:1 on an empty vault, then supply / balance, rounding down
function deposit(uint256 assets) external returns (uint256 shares) {
uint256 supply = totalSupply;
uint256 bal = asset.balanceOf(address(this));
shares = supply == 0 ? assets : (assets * supply) / bal;
require(asset.transferFrom(msg.sender, address(this), assets), "transfer");
totalSupply = supply + shares;
balanceOf[msg.sender] += shares;
}
function donate(uint256 assets) external {
require(asset.transferFrom(msg.sender, address(this), assets), "donate");
// no shares minted
}
function redeem(uint256 shares) external returns (uint256 assets) {
uint256 bal = asset.balanceOf(address(this));
assets = (shares * bal) / totalSupply;
balanceOf[msg.sender] -= shares;
totalSupply -= shares;
require(asset.transfer(msg.sender, assets), "out");
}
}
Use a one-function mock ERC-20 with mint in the same file (standard Foundry pattern: balances mapping, transferFrom pulls from the caller). Then:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
/// ILLUSTRATIVE mock — no allowance checks; local lab only
contract MockAsset {
mapping(address => uint256) public balanceOf;
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
}
function transfer(address to, uint256 amount) external returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
balanceOf[from] -= amount;
balanceOf[to] += amount;
return true;
}
}
/// LOCAL LAB ONLY — ToyInflationVault from the previous snippet
contract InflationLabTest is Test {
function test_donationRoundsVictimToZero() public {
MockAsset asset = new MockAsset();
ToyInflationVault vault = new ToyInflationVault(IERC20Mini(address(asset)));
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
asset.mint(attacker, 1001);
asset.mint(victim, 1000);
vm.startPrank(attacker);
assertEq(vault.deposit(1), 1);
vault.donate(1000);
vm.stopPrank();
vm.prank(victim);
assertEq(vault.deposit(1000), 0, "1000 * 1 / 1001 == 0");
assertEq(vault.balanceOf(victim), 0);
assertEq(asset.balanceOf(address(vault)), 2001);
}
}
The assertion is the lab’s point: 1000 * 1 / 1001 in integer division is 0. That matches OZ’s “a0 = 1, a1 = u zeros a deposit of u” for u = 1000. Redeeming the attacker’s 1 share afterward pays the full vault balance (they hold the only shares). Add that assertion locally if you want; do not point it at a deployed vault.
Virtual offset, same numbers, still local. Using the OZ shape with decimalsOffset = 0 at the moment of the victim’s deposit (supply 1, balance 1001, deposit 1000):
shares = 1000 * (1 + 10**0) / (1001 + 1) = 2000 / 1002 = 1
The victim receives 1 share, not 0. That single integer is an illustration of the formula, not a proof that every offset-0 vault is safe. The profitability argument stays in the OZ guide. In your write-up, quote the formula and this integer, and say you did not run it against a deployed vault.
Lab write-up (five sentences): empty-vault formula you used, donation size versus victim deposit, shares the victim actually minted, what the virtual offset did to that integer, and why a flash loan would only have funded a1.
totalAssets (balance vs internal ledger) and the exact convert formulaa0 + a1 relative to u), not a sloganpreview* used as a price,” file it as an oracle assumption and link the consumer| Trap | Better habit |
|---|---|
| “Any donation is theft” | Show the next depositor rounding to dust, and that the attacker can redeem it |
| Donating and redeeming your own shares, expecting profit | That gifts value to existing shareholders |
| Ignoring virtual offset already in the code | Read _convertToShares before writing the report |
Treating previewDeposit as a safe oracle | ERC-4626 says previews are manipulable |
| Minimum deposit as a complete fix | It raises empty-vault cost; it is not the OZ offset |
| Forking a live vault to try the donation | Local toy only |
totalAssets source identified (balance, internal counter, strategy NAV)shares == 0 (or dust) caseEducational content by Syed Zada Abrar for Andrax (andraxpentester.in). Authorized research only. The vulnerable vault is ILLUSTRATIVE / local lab only.
How to write an Immunefi-ready bug report: structure, PoC expectations, severity language — template practice only.
40 min read