On March 17, 2026, an attacker deposited 772 USDC into dTRINITY's dLEND pool on Ethereum. The protocol's accounting system interpreted this as $4.8 million in collateral. The attacker borrowed $257,000 in dUSD, looped 127 deposit-withdraw cycles to drain the remaining liquidity, and walked away before anyone noticed.
This wasn't novel. It wasn't clever. It was the exact same bug class that hit Cream Finance ($130M), Hundred Finance ($7.4M), and a dozen other protocols. Share inflation attacks keep happening because teams keep shipping the same three missing lines of code.
This article breaks down the kill chain, explains why the standard fixes fail, and provides a definitive prevention pattern you can deploy today.
The Kill Chain: How Share Inflation Attacks Work
Every modern lending protocol uses share-based accounting. When you deposit 1,000 USDC, you receive shares proportional to the pool's exchange rate:
shares = depositAmount × totalShares / totalAssets
The vulnerability appears at pool initialization, when totalShares and totalAssets are both zero (or near-zero).
Step 1: Seed the Pool
The attacker deposits a tiny amount — say 1 wei of USDC — receiving 1 share.
Step 2: Donate to Inflate
The attacker transfers a large amount of USDC directly to the pool contract (bypassing the deposit() function). Now:
totalAssets = 1_000_000e6 (donated USDC)
totalShares = 1 (attacker's single share)
exchangeRate = 1_000_000e6 per share
Step 3: Front-Run the Victim
When the next depositor puts in 500,000 USDC:
shares = 500_000e6 × 1 / 1_000_000e6 = 0 (rounds down!)
The victim receives zero shares for half a million dollars. Their deposit is absorbed into the attacker's single share.
Step 4: Withdraw
The attacker redeems their 1 share for the entire pool balance: their original donation + the victim's deposit.
The dTRINITY Variant
dTRINITY's exploit was a twist on this pattern. Instead of direct donation, the attacker exploited a stale liquidity index — the protocol didn't update its interest accumulator before processing deposits. This created a temporal mismatch where deposits were valued at a stale (lower) index while borrows used an updated (higher) index, producing phantom collateral from thin air.
The 127-loop extraction amplified rounding errors across each cycle, turning micro-cent rounding dust into $257,000 of real USDC.
Why the "Standard" Fixes Keep Failing
Fix Attempt #1: Minimum Deposit Requirements
require(assets >= MIN_DEPOSIT, "Below minimum");
Why it fails: Attackers can meet the minimum and still inflate shares through direct transfers. The minimum protects against dust attacks but not economic manipulation.
Fix Attempt #2: First Depositor Lockup
Some protocols lock the first depositor's shares permanently.
Why it fails: Only prevents the simplest variant. Doesn't protect against index manipulation (dTRINITY), cross-function accounting mismatches, or donation attacks after the first deposit.
Fix Attempt #3: "Just Update the Index"
function deposit(uint256 assets) external {
_updateIndex(); // "Fixed!"
// ... rest of deposit logic
}
Why it still fails: If _updateIndex() is called in deposit() but not in borrow(), or vice versa, the temporal mismatch persists. The fix must be universal and atomic — every state-changing function must operate on the same index snapshot.
The Definitive Fix: Virtual Shares + Invariant Enforcement
The only pattern that comprehensively prevents share inflation attacks combines three mechanisms. Here are the three lines of code that keep getting omitted:
Line 1: Virtual Shares (ERC-4626 §4)
uint256 private constant VIRTUAL_SHARES = 1e6;
uint256 private constant VIRTUAL_ASSETS = 1;
function _convertToShares(uint256 assets) internal view returns (uint256) {
return assets.mulDiv(
totalSupply() + VIRTUAL_SHARES,
totalAssets() + VIRTUAL_ASSETS,
Math.Rounding.Floor
);
}
function _convertToAssets(uint256 shares) internal view returns (uint256) {
return shares.mulDiv(
totalAssets() + VIRTUAL_ASSETS,
totalSupply() + VIRTUAL_SHARES,
Math.Rounding.Floor
);
}
Virtual shares create a permanent "phantom depositor" in the pool. Even when totalShares = 0 and totalAssets = 0, the exchange rate calculation uses VIRTUAL_SHARES and VIRTUAL_ASSETS as a floor, making inflation economically impractical.
The math: To inflate the exchange rate by 10x with VIRTUAL_SHARES = 1e6, an attacker would need to donate 10 × 1e6 = 10M units of the underlying asset — making the attack cost more than any possible profit.
Line 2: Atomic Index Updates
modifier syncIndex() {
_updateLiquidityIndex();
_;
_checkInvariant();
}
function deposit(uint256 assets) external syncIndex { ... }
function withdraw(uint256 shares) external syncIndex { ... }
function borrow(uint256 assets) external syncIndex { ... }
function repay(uint256 assets) external syncIndex { ... }
Every state-changing function must update the index before execution. No exceptions. The modifier pattern ensures consistency can't be accidentally broken when adding new functions.
Line 3: Post-Operation Invariant Check
function _checkInvariant() internal view {
uint256 realAssets = underlying.balanceOf(address(this));
uint256 accountedAssets = _convertToAssets(totalSupply());
// Allow 0.01% tolerance for rounding
uint256 tolerance = realAssets / 10_000;
require(
realAssets >= accountedAssets - tolerance &&
realAssets <= accountedAssets + tolerance,
"INVARIANT_BROKEN"
);
}
This is the circuit breaker. If real assets and accounted assets diverge beyond tolerance, the transaction reverts. This catches every variant of share inflation — donation, index manipulation, rounding amplification — because they all produce the same symptom: a mismatch between what the pool holds and what it thinks it holds.
Testing the Fix: Foundry Invariant Suite
Prevention code is useless if it's not tested against adversarial conditions. Here's a production-ready Foundry invariant test suite:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {LendingPool} from "../src/LendingPool.sol";
import {MockERC20} from "./mocks/MockERC20.sol";
contract LendingPoolHandler is Test {
LendingPool public pool;
MockERC20 public token;
uint256 public ghost_totalDeposited;
uint256 public ghost_totalWithdrawn;
constructor(LendingPool _pool, MockERC20 _token) {
pool = _pool;
token = _token;
}
function deposit(uint256 amount) external {
amount = bound(amount, 1, 1e24);
token.mint(address(this), amount);
token.approve(address(pool), amount);
pool.deposit(amount);
ghost_totalDeposited += amount;
}
function withdraw(uint256 shares) external {
uint256 maxShares = pool.balanceOf(address(this));
if (maxShares == 0) return;
shares = bound(shares, 1, maxShares);
uint256 assets = pool.withdraw(shares);
ghost_totalWithdrawn += assets;
}
// Simulate donation attack
function donate(uint256 amount) external {
amount = bound(amount, 1, 1e24);
token.mint(address(this), amount);
token.transfer(address(pool), amount);
}
}
contract LendingPoolInvariantTest is StdInvariant, Test {
LendingPool pool;
MockERC20 token;
LendingPoolHandler handler;
function setUp() public {
token = new MockERC20("USDC", "USDC", 6);
pool = new LendingPool(address(token));
handler = new LendingPoolHandler(pool, token);
targetContract(address(handler));
}
function invariant_realAssetsMatchAccounting() public view {
uint256 realBalance = token.balanceOf(address(pool));
uint256 totalShares = pool.totalSupply();
if (totalShares == 0) return;
uint256 accountedAssets = pool.convertToAssets(totalShares);
uint256 tolerance = realBalance / 10_000 + 1;
assertGe(realBalance + tolerance, accountedAssets,
"Real assets below accounted assets");
}
function invariant_noShareInflation() public view {
// Exchange rate should never exceed a reasonable bound
uint256 totalShares = pool.totalSupply();
if (totalShares == 0) return;
uint256 rate = pool.convertToAssets(1e18);
assertLe(rate, 1e24, "Exchange rate suspiciously high");
}
function invariant_withdrawNeverExceedsDeposit() public view {
assertLe(
handler.ghost_totalWithdrawn(),
handler.ghost_totalDeposited() +
token.balanceOf(address(pool)),
"Withdrawn more than deposited + donations"
);
}
}
Run with high depth to catch multi-step exploits:
forge test --match-contract LendingPoolInvariantTest \
-vvv \
--invariant-runs 512 \
--invariant-depth 128
The depth=128 setting mirrors dTRINITY's 127-loop attack. If your invariant tests pass at this depth, you've validated against the exact extraction pattern that drained $257K.
The Audit Checklist: 9 Questions Every Lending Protocol Must Answer
Before deploying any share-based lending protocol, verify:
Share Accounting
- Are virtual shares implemented? Without them, the first-depositor inflation vector is always open.
-
Does
convertToShares()round down for deposits? Rounding up gives depositors unearned shares. -
Does
convertToAssets()round down for withdrawals? Rounding up lets withdrawers extract more than their share.
Index Synchronization
- Is the liquidity/interest index updated before EVERY state change? Check every function: deposit, withdraw, borrow, repay, liquidate, flashloan callbacks.
- Are all functions using the SAME index snapshot within a transaction? A stale index in one function + fresh index in another = dTRINITY.
- Are index updates atomic with the operations they support? No external calls between index update and state change.
Invariant Enforcement
-
Is there a post-operation balance check?
realAssets ≈ accountedAssetsafter every operation. - Is there a circuit breaker for abnormal deviations? Auto-pause when the gap exceeds tolerance.
- Are donation attacks handled? Direct transfers to the pool contract must not break the exchange rate.
The Hall of Shame: Share Inflation's $150M+ Track Record
| Protocol | Date | Loss | Root Cause |
|---|---|---|---|
| Cream Finance | Oct 2021 | $130M | Flash loan + share inflation |
| Hundred Finance | Apr 2023 | $7.4M | Empty market manipulation |
| Euler Finance | Mar 2023 | $197M | Accounting mismatch (partial) |
| Wise Lending | Jan 2024 | $460K | Donation attack |
| Sonne Finance | May 2024 | $20M | Fork market manipulation |
| dTRINITY | Mar 2026 | $257K | Index sync + loop extraction |
Every single one of these would have been prevented by virtual shares + invariant enforcement.
Conclusion: Three Lines, Zero Excuses
The share inflation attack class is completely solved. OpenZeppelin's ERC-4626 implementation includes virtual shares by default since v5.0. Foundry's invariant testing framework can validate accounting consistency in minutes. The dTRINITY team has pledged to cover losses from internal funds — but the next team might not be so well-capitalized.
If you're building or auditing a lending protocol in 2026:
- Use virtual shares. Copy OpenZeppelin's implementation verbatim.
- Sync your index atomically. Use a modifier, not manual calls.
-
Check your invariants on-chain. If
realAssets ≠ accountedAssets, revert.
Three lines of code. $150 million in preventable losses. The pattern is known. The fix is known. Ship it.
This article is part of the DeFi Security Research series. Follow @ohmygod for weekly deep-dives into blockchain security vulnerabilities and defense patterns.
Top comments (0)