2025-01-28·10 min read

The Anatomy of a Critical DeFi Bug: Insolvency Through Incorrect Accounting

By Alex · Security researcher

The Anatomy of a Critical DeFi Bug: Insolvency Through Incorrect Accounting

Accounting bugs are the silent killers of DeFi. They don't announce themselves with dramatic exploits or flash loan attacks. Instead, they slowly bleed a protocol dry, one transaction at a time, until the vault is insolvent and the last users to withdraw are left holding nothing.

In this post, we'll dissect a real class of critical vulnerability: share accounting errors that lead to protocol insolvency. This is the exact pattern we found during our Corn engagement, and it remains one of the most common critical findings across DeFi audits.

The Setup: ERC-4626 Vault Accounting

Most DeFi vaults follow the ERC-4626 tokenized vault standard. Users deposit an underlying asset and receive shares in return. The fundamental relationship is:

shares = deposit * totalSupply / totalAssets
assets = shares * totalAssets / totalSupply

This seems simple enough. But the devil is in the rounding.

The Buggy Pattern

Consider this share minting logic:

contract BuggyVault {
    uint256 public totalAssets;
    uint256 public totalShares;

    mapping(address => uint256) public shareBalance;

    function deposit(uint256 assets) external {
        // BUG: Rounding UP shares minted to the depositor
        uint256 shares = (assets * totalShares + totalAssets - 1) / totalAssets;

        totalAssets += assets;
        totalShares += shares;
        shareBalance[msg.sender] += shares;

        // transfer assets in...
    }

    function withdraw(uint256 shares) external {
        // Correctly rounds DOWN assets returned to the withdrawer
        uint256 assets = (shares * totalAssets) / totalShares;

        totalShares -= shares;
        totalAssets -= assets;
        shareBalance[msg.sender] -= shares;

        // transfer assets out...
    }
}

Do you see the bug? The deposit function rounds up the number of shares minted. This means every depositor receives slightly more shares than they mathematically deserve. Meanwhile, withdraw rounds down correctly, but it's too late: the shares have already been over-allocated.

The Attack Path

Here is how insolvency develops step by step:

  1. Alice deposits 1000 USDC when the vault is empty. She gets 1000 shares. So far, so good.
  2. Yield accrues, bringing totalAssets to 1100 with 1000 shares outstanding. Each share is worth 1.1 USDC.
  3. Bob deposits 100 USDC. He should get 100 * 1000 / 1100 = 90.909... shares. With the rounding-up bug, he gets 91 shares.
  4. Bob immediately withdraws his 91 shares: 91 * 1100 / 1091 = 91.75 USDC. He deposited 100 and got back ~91.75 — he lost money on this particular trade due to rounding against him on withdrawal.

But that is not where the exploit lives. The real damage shows up at scale:

  1. An attacker repeats small deposits thousands of times. Each deposit rounds up by 1 share. After 1000 deposits of 1 USDC each, the attacker might hold 1000 extra shares that are not backed by real assets.
  2. The attacker withdraws everything. The vault now owes legitimate depositors more than it holds.

The Fix: Always Favor the Protocol

The correct pattern is simple: round against the user on every operation.

  • Deposits: Round shares down (user gets fewer shares)
  • Withdrawals: Round assets down (user gets fewer assets)
  • Minting: Round assets up (user must deposit more)
  • Redeeming: Round shares up (user must burn more)
contract FixedVault {
    function deposit(uint256 assets) external {
        // FIXED: Round DOWN shares minted (favor the protocol)
        uint256 shares = (assets * totalShares) / totalAssets;

        totalAssets += assets;
        totalShares += shares;
        shareBalance[msg.sender] += shares;
    }

    function withdraw(uint256 shares) external {
        // Round DOWN assets returned (favor the protocol)
        uint256 assets = (shares * totalAssets) / totalShares;

        totalShares -= shares;
        totalAssets -= assets;
        shareBalance[msg.sender] -= shares;
    }
}

In practice, production vaults should use OpenZeppelin's Math.mulDiv with explicit rounding direction:

uint256 shares = Math.mulDiv(assets, totalShares, totalAssets, Math.Rounding.Floor);

Why Unit Tests Miss This

A typical unit test for deposits might look like:

function test_deposit() public {
    vault.deposit(1000e18);
    assertEq(vault.balanceOf(address(this)), 1000e18);
}

This test passes perfectly. The rounding error is 0 when shares and assets are 1:1. It only manifests after yield accrues and the exchange rate deviates from 1:1, after many operations compound the error, and after specific sequences of deposits and withdrawals.

No developer is going to write a unit test that simulates 5000 sequential deposits of 1 wei after a yield event. But a fuzzer will.

Why This Bug Class Is So Common

Share-to-asset accounting bugs are the single most common critical finding in DeFi security audits. We see them in vaults, lending pools, staking contracts, and reward distributors. The pattern repeats across codebases because the underlying math looks deceptively simple.

The root cause is always the same: the conversion between shares and assets must round consistently, and the rounding direction must favor the protocol. On deposit or mint, round down the shares the user receives. On withdraw or redeem, round down the assets the user gets back. Every deviation from this rule creates a leak.

ERC-4626 codified the correct rounding behavior explicitly. The standard's reference implementation calls out Math.Rounding.Floor and Math.Rounding.Ceil at every conversion point. But many protocols implement their own vault logic outside of ERC-4626 — custom staking pools, reward accumulators, liquidity management layers — and they get the rounding wrong because they do not think of themselves as "vault contracts" subject to these same rules.

The bug is invisible in unit tests because it only manifests after a specific sequence of state transitions: deposit, price change (from rewards or direct donations to the vault), deposit again, withdraw. Each step works correctly in isolation. The rounding error at any single step is at most 1 wei. No individual assertion will flag it.

But an attacker does not need to exploit a single rounding error. They automate thousands of operations in a single transaction using a loop or a contract that calls deposit and withdraw repeatedly. Each cycle extracts a fraction of a wei more than it should. At scale, the vault bleeds out. The attacker walks away with a profit proportional to the number of iterations, and the remaining depositors share the loss.

The Invariant That Catches It

The property that reliably catches this entire class of bugs is surprisingly simple:

function invariant_solvency() public returns (bool) {
    uint256 totalClaimable = 0;
    for (uint256 i = 0; i < actors.length; i++) {
        totalClaimable += vault.convertToAssets(vault.balanceOf(actors[i]));
    }
    return vault.totalAssets() >= totalClaimable;
}

This property says: "The vault must always hold enough assets to pay out every shareholder." If share minting is too generous, eventually totalClaimable exceeds totalAssets, and the invariant breaks.

During our Corn engagement, this exact class of property identified the critical accounting bug within hours of starting the fuzzing campaign. The fix was applied, verified with continued fuzzing, and the protocol launched safely.

Detecting Accounting Bugs with Invariant Testing

Beyond the solvency check above, there are more granular invariants that catch accounting bugs earlier and with clearer diagnostics.

The simplest invariant is the one we already showed: totalAssets() >= convertToAssets(totalSupply()) — the vault must never owe more than it holds. But you can go further. Track each user's cumulative deposits and ensure no user can withdraw more than they deposited plus their proportional share of any rewards that accrued. This catches share dilution attacks where one depositor's gain comes at another depositor's expense, not just attacks that drain the vault below zero.

The fuzzer needs handler functions that simulate realistic scenarios. Write target functions for deposits of varying sizes (from 1 wei to the maximum balance), reward distributions that change the exchange rate at different intervals, and withdrawals of both partial and full positions. Include a handler that donates tokens directly to the vault — this simulates the reward accrual that shifts the share-to-asset ratio and triggers rounding edge cases.

Run with multiple actors — at least three. Share dilution issues only appear with concurrent depositors because the rounding error from one user's deposit shifts the exchange rate that the next user's deposit is priced against. A single-actor test cannot observe this.

Duration matters. Some accounting bugs only manifest after 20 to 40 operations in a specific sequence. Short fuzzing campaigns with a sequence length of 10 will miss them entirely. Set your sequence length to at least 50 and run a minimum of 100,000 test iterations. For production assurance, run millions.

Key Takeaways

  1. Rounding direction is a security decision, not a math detail. Always round in favor of the protocol.
  2. Single-operation rounding errors compound across thousands of transactions into exploitable insolvency.
  3. Solvency invariants are the most important property you can write for any vault or lending protocol.
  4. Invariant testing finds these bugs systematically where manual review and unit tests consistently miss them.

If your protocol manages user funds and you have not validated your share accounting with invariant testing, you are flying blind. Request an audit with Recon and let us prove your accounting is correct — or find the bug before an attacker does.

Related Posts

Related Glossary Terms

Related Case Studies

Need help securing your protocol?