All writing

ERC-4626 Share Inflation Is a Write Authority Bug

· 14 min read

  • architecture
  • solidity
  • defi
  • vaults

ERC-4626 Share Inflation Is a Write Authority Bug

OpenZeppelin's vault base answers the question of what the vault owns in a single line, and that line is a balance read. the OpenZeppelin vault base, totalAssets

Any address can raise that number by sending the token to the vault. No share is minted for it, nothing records it, and the next depositor is priced against it.

What comes out of that has two popular names and both of them name the attacker's move. Share inflation names what happens to the price. The donation attack names how it happens. So a reader who learns either name goes looking at the transfer, and the transfer isn't where the repair lives.

The price is a ratio and only one side is the vault's to write

A vault's share price is its assets divided by its shares, and the standard fixes only one side of it: the shares move when the vault mints or burns, while who writes the assets is left to the implementation, which in OpenZeppelin's base is a token balance any address can raise by sending a transfer.

  the price a deposit is quoted at
      = totalAssets() / totalSupply
 
  totalSupply                  totalAssets(), as the default reads it
      |                            |
      +-- deposit()  mints         +-- deposit()   the balance rises
      +-- redeem()   burns         +-- redeem()    the balance falls
                                   +-- transfer()  from any address at all,
                                       and no share is minted for it
 
  two writers on the left. three on the right, and the third one is anybody.

That choice is older than the standard. Compound's cToken computes its exchange rate from total cash plus total borrows minus total reserves, over total supply. the Compound market contract, exchangeRateStoredInternal Total cash is a call that contract leaves abstract, and its ERC-20 market answers it with the token's balance of the market itself. the Compound ERC-20 market, getCashPrior The lending market and the tokenized vault are the same design decision, made twice, in two codebases that don't reference each other.

The standard's own security section knows the numbers can be moved. It says the preview methods are manipulable by altering the on chain conditions, and it aims that warning at integrators reading a vault as a price oracle. ERC-4626 specification, security considerations Then it turns to the part it treats as the real hazard: which way each function should round, in both directions, so that the vault is favoured over its users. ERC-4626 specification, the rounding directions

Read those two together and the standard has seen the manipulation and filed it under somebody else's problem. What it hands the deposit path is a rounding rule and a suggestion to wrap the call against slippage, and neither of those says who may write the denominator. If you're reading a vault contract and you want one place to look, it's the body of totalAssets(). Who else can move what it returns?

The field followed. One auditor's published analysis names four lending markets that lost funds to this shape between April 2023 and May 2024, and it opens by naming the root cause of the largest as precision loss. CertiK, Sonne Finance incident analysis

What happened in the largest is worth holding next to the name. One published analysis of the exploit transaction has the attacker borrowing about two hundred and sixty five ether against a single wei of a market token. Verichains, the Compound fork vulnerability A second analysis of the same transaction puts the collateral at two wei. CertiK, the same transaction Precision loss is a true description of the last step of that. It isn't a description of how a couple of wei came to be worth that much.

I have sat with someone from outside the work and gone through code that moves money until they could read it on their own. That's the test a name has to pass. Share inflation and donation attack both pass on the first reading and fail on the second, because each one describes a thing the attacker did and neither describes a thing the vault decided.

The strongest case for leaving the balance read alone

The serious alternative is to keep the live balance read and put virtual shares and virtual assets over it, because a vault that reads its own balance owns every asset that reaches it, including the yield that arrives with no call attached.

That position is the one shipping today. OpenZeppelin argues it in the file itself: the caution above their vault base calls the failure a donation or inflation attack and reads it as a problem of slippage. the OpenZeppelin vault base, the first caution Every mitigation they list follows from that reading. Seed the vault yourself so the price can't be moved cheaply. Wrap the deposit in a router that checks what you received. Raise the decimal offset, which mints virtual shares against a virtual asset by multiplying the share side by totalSupply() + 10 ** _decimalsOffset() and dividing by totalAssets() + 1. the OpenZeppelin vault base, the conversion It ships at zero, which is already one virtual share against one virtual asset rather than none, and it's the mitigation their own file recommends.

The case for it is stronger than the case against the ledger I am about to argue for, on one axis that matters more than any other: a vault that reads its own balance is correct about a staking reward, an airdrop, a fee routed in by a peripheral contract, and a rebase, all without being told. Every one of those is an asset that arrives with nobody calling deposit. The ledger has to be told about each of them by hand, and a route somebody adds later and forgets to wire is money the vault won't count.

That is the case the alternative wins, so it belongs in the harness, and the first version of mine didn't have it. A yield source sends a whole token to each vault with nobody calling deposit. The balance read vault returns the depositor his 2 tokens and the whole token of yield, and the ledger vault returns the 2 tokens, strands the token of yield, and reports it as a surplus no share can reach.

And their claim about profitability holds at the amounts in the harness. I put the offset into the harness at its shipped default and pointed the same attack at it. The attacker spends 1.000000000000000001 tokens and recovers 0.6, so at these amounts the attack costs him four tenths of a token.

The depositor is a different question, and it is the one the framing hides. In the same run the depositor puts in 2 tokens and takes out 1.800000000000000001, and the 0.6 tokens nobody recovers stay in the contract, claimable by no share. OpenZeppelin's own text says where that value goes: it is captured by the virtual shares. the OpenZeppelin vault base, the offset rationale Non profitable is a statement about the attacker's balance sheet. The depositor pays either way, and with the offset switched on the money isn't returned to him, it's destroyed.

Give the denominator one writer

Hold the asset total in a storage variable the vault writes in the same step it mints, so that every asset the price divides by is an asset some share was minted for.

Role: reduced executable model, the three vaults excerpted from the harness that ran.

abstract contract Vault {
    // ...
    function previewDeposit(uint256 assets) public view virtual returns (uint256) {
        uint256 minted = totalSupply;
        return minted == 0 ? assets : assets * minted / totalAssets();
    }
 
    // ...
    function deposit(uint256 assets) external returns (uint256 shares) {
        shares = previewDeposit(assets);
        require(shares != 0, "ZERO_SHARES");
        asset.transferFrom(msg.sender, address(this), assets);
        _onDeposit(assets);
        totalSupply += shares;
        shareOf[msg.sender] += shares;
    }
 
    // ...
}
 
// ...
 
contract BalanceVault is Vault {
    constructor(Asset a) Vault(a) {}
 
    function totalAssets() public view override returns (uint256) {
        return asset.balanceOf(address(this));
    }
}
 
// ...
 
contract LedgerVault is Vault {
    uint256 private accounted;
 
    // ...
    function totalAssets() public view override returns (uint256) {
        return accounted;
    }
 
    function _onDeposit(uint256 assets) internal override {
        accounted += assets;
    }
 
    function _onRedeem(uint256 assets) internal override {
        accounted -= assets;
    }
 
    // ...
    function unaccounted() external view returns (uint256) {
        return asset.balanceOf(address(this)) - accounted;
    }
}

The rounding is the same in both. The zero share guard is the same in both. The one difference is who may write what totalAssets() returns, in the reduced harness, one asset, no fees and no accrual.

Then one script run against each, and if you want to watch the difference appear, watch the share count the second deposit gets. An attacker deposits one wei and gets one share. He sends a whole token to the vault address. A depositor arrives and deposits two tokens.

On the balance read vault the depositor gets 1 share for those 2 tokens, redeems it for 1.5, and the attacker walks off with the other half token. On the ledger vault the same depositor gets back the whole 2 tokens, and the attacker recovers his 1 wei and nothing else, because the token he sent belongs to no share and no share can reach it. Over 256 fuzzed pairs of donation and deposit the ledger's worst case stays at or under one wei.

That gap is what tells you which story is true. If the loss came from rounding, two vaults rounding identically would lose identically. So I inverted the rounding direction in the one function the two vaults above share and reran: every assertion about the attack on the balance read vault broke, and no assertion about the ledger vault moved.

Solmate's vault reverts a deposit that would mint zero shares, which is the rounding repair in its cleanest form. the Solmate vault, the deposit guard It's worth having and it buys less than it looks. In the harness that guard blocks the deposit of half a token, which was the total loss case, and passes the deposit of 2 tokens, which is the quarter loss case. The guard moved the depositor from losing everything to losing a quarter, and then stopped.

There is a second caution in that same OpenZeppelin file, forty eight lines below the first, and it points the other way. It says that minting shares with no matching increase in the vault's assets alters the exchange rate, and it names the flash mint extension as the thing you must not combine the vault with. the OpenZeppelin vault base, the second caution Shares moving without assets is written up as corrupting the accounting. Assets moving without shares is written up as slippage. It's one invariant read from its two ends, and only one end got treated as an accounting problem.

What the ledger buys is a checkable invariant. The vault's own total and the token's balance of the vault are now two numbers, and their difference is the exact quantity nobody has minted a share against. Uniswap V2 named that difference in 2020 and gave it a function. the Uniswap pair contract, skim and sync

What the ledger costs, and who pays

A ledger denominator turns every route by which an asset can reach the vault into a code path somebody has to write, and it makes the vault's token balance a number the vault itself disagrees with.

The vault engineer pays first, and pays on the day a new inflow is added rather than the day the ledger is written. A yield source wired in six months later that pushes tokens by transfer will be counted by nothing, and nothing raises its hand: the vault stays solvent, the price stays right, and the yield simply doesn't arrive. The one number that would tell you is the surplus the ledger already exposes, and it tells you only if somebody reads it.

The integrator pays next, and pays with an assumption that used to hold. Anyone reading the vault's token balance as the vault's assets now reads a figure the vault disagrees with, and the two only reconcile if the reader knows to ask the vault instead of the token.

The depositor pays nothing on the attack, which is the whole point of the section above it. He pays on the other case, and the section after this one is where that lands: on a ledger vault a token that arrives with nobody calling is a token he doesn't get.

The donor pays everything, and that's a real cost and not a joke: on a ledger vault a token sent to the adress is unreachable through any share, so the same gesture that used to be an attack becomes a permanent loss for whoever makes it by accident.

This section prices none of that in gas or in engineer time, on purpose: the reconciliation path is where the real cost sits, and it is per vault, so a number here would be a number about my harness, and that isn't the number you want.

When the denominator is the wrong place for the bound

The bound moves off the denominator and onto the authority to fold a surplus in, the moment the vault's assets start arriving by transfer rather than by call.

The Uniswap pair contract is the case worked all the way through. Its reserves are storage variables, a deposit is credited with the difference between the live balance and the stored reserve, and the surplus that difference exposes has two functions of its own: skim sends it away, and sync folds it into the reserves. the Uniswap pair contract, mint Both are external, and neither asks who is calling.

So I added one line to the ledger vault, accounted = asset.balanceOf(address(this)); behind a public sync, and reran the script. The depositor's outcome went back to 1.5 tokens out of 2, to the wei. The ledger didn't fail. The ledger was told to adopt a balance, by a caller who was allowed to tell it that, which is the same act the balance read performs on every call for free.

And that same public function is what the yield case needs. The same public function, called after the yield arrives, returns the depositor his 2 tokens and the token of yield. One function buys back exactly what the ledger stranded and hands back exactly what the ledger bought. On the attack script the synced ledger returns the depositor 1.5 of 2, and the virtual offset I argued against, at its shipped default, returns him 1.800000000000000001. In the regime this section concedes, the arrangement I rejected is the better of the two, and I'd rather say so here than leave the reader to notice it.

That's the honest boundary of the recommendation. A vault whose assets genuinely arrive without a call needs the surplus folded in by somebody, and the question stops being where the price reads from and becomes who is allowed to fold, on what evidence, and with what delay. Price one quantity against another only where the same paths write both, because a live balance on either side hands the price to whoever can move it.

What this piece does not establish

Written from nine retained files and one reduced Foundry harness, the sources retrieved on 2026-09-06 and the harness last executed on 2026-09-07. Six are code or specification files, each fetched and compared byte for byte against the commit it is pinned at; one is the commit record that dates the Uniswap pair; the last two are the incident analyses, web pages carrying no commit. No deployed vault was read, no live market was operated, and the harness models one asset with no fees, no accrual and no borrowing, so nothing here measures what any of this costs in production. The mutation campaign left one survivor and it is the declared control.

The place I have made this call myself is a settlement venue whose assets moved on deposit and withdraw, so the case that argues against me, an asset arriving with nobody calling, is not one I have had to answer. That comparison was against per swap external transfers and against batched netting, and internal accounting won on cost, which is a different axis from the one this piece turns on.

This piece does not establish what the right denominator is for a vault whose assets arrive without a call, and nothing here was run against a deployed vault.


Abdel KIARI

I’ve owned the EVM side of a DeFi protocol with $110M+ in funding, including backing from Coinbase Ventures. I redesigned its architecture from scratch and built the Solidity infrastructure end to end.

Always happy to talk about interesting opportunities

abdel.kiari@gmail.com