Euler Finance Hack: $197M Lost to a Donation-and-Liquidate Bug

Euler Finance lost $197M in March 2023 because donateToReserves() let an attacker self-liquidate at a discount. One missing solvency check turned a charitable function into the largest DeFi exploit of the year.

The Euler Finance donation attack exploited donateToReserves(), which let a user gift eTokens to the protocol reserves without a health check. The attacker borrowed against deposited collateral, donated enough eTokens to push their own account underwater, then self-liquidated. Euler's liquidation logic awarded a soft-liquidation discount that transferred more collateral than the debt being absorbed, letting the attacker extract $197M across multiple tokens. The root cause: a state-mutating function skipped checkLiquidity(), violating the invariant that no action should leave an account…
··7 min read

The Bug in One Sentence

Euler's `donateToReserves()` function let any account voluntarily destroy its own eToken balance to top up protocol reserves — but it forgot to verify the donor was still solvent afterward. That single missing check cost $197M.

How Euler's Accounting Worked

Euler used a two-token model:

**eTokens** — interest-bearing claim on deposited collateral (like Aave's aTokens)
- **dTokens** — tokenized debt position

A user deposits USDC, receives eUSDC, and can borrow against it, minting dUSDC. Health is measured as collateral value vs. debt value, scaled by per-asset risk factors.

Euler also supported **leveraged positions** via `mint()`, which lets a user simultaneously mint eTokens and dTokens against themselves — a self-borrow that inflates both sides of the balance sheet. This is fine in isolation because the position nets to zero risk… until you let the user delete one side of it.

The Vulnerable Function

Here is the simplified pre-patch logic:

```solidity
function donateToReserves(uint subAccountId, uint amount) external {
address account = getSubAccount(msg.sender, subAccountId);

updateAverageLiquidity(account);

AssetStorage storage assetStorage = eTokenLookup[address(this)];
AssetCache memory assetCache = loadAssetCache(...);

uint balance = assetStorage.users[account].balance;
require(balance >= amount, "insufficient balance");

// burn donor's eTokens
assetStorage.users[account].balance = balance - amount;
// credit reserves
assetCache.reserveBalance += amount;

// <-- NO checkLiquidity(account) HERE
}
```

Compare with literally every other state-changing function in Euler — `withdraw`, `borrow`, `transfer` — all of which end with a `checkLiquidity()` call. `donateToReserves` was the only mutating path that skipped it. Why? Probably because the dev assumed "giving money away can't make you riskier." It can, if you have outstanding debt.

The Attack, Step by Step

1. **Flash loan** 30M DAI from Aave.
2. **Deposit** 20M DAI into Euler → receive ~19.6M eDAI.
3. **Mint** maximum leverage: 195.7M eDAI and 200M dDAI against the position (Euler allowed ~10x via `mint()`).
4. **Repay** 10M DAI of debt with the remaining flash loan funds — bringing the position close to healthy but still highly leveraged.
5. **Mint again** — now position holds ~390M eDAI / 390M dDAI.
6. **Donate** 100M eDAI to reserves. Position is now insolvent: collateral dropped, debt unchanged.
7. **Self-liquidate** from a secondary account. Euler's soft-liquidation logic granted a dynamic discount (up to 20%) scaling with how underwater the position was. The liquidator absorbed 260M dDAI and seized 310M eDAI — a 50M DAI net gift.
8. **Withdraw** the seized eDAI as real DAI from the pool, repay the flash loan, keep the rest.

Repeat across DAI, USDC, WBTC, stETH. Total haul: ~$197M.

The attacker famously [returned all the funds](https://etherscan.io/) weeks later after on-chain negotiation, but that's irrelevant to the bug class — the same primitive has appeared in forks and unrelated lending protocols since.

The Fix

Euler's patch ([eIP-14](https://github.com/euler-xyz/euler-contracts)) added the obvious missing line:

```solidity
function donateToReserves(uint subAccountId, uint amount) external {
address account = getSubAccount(msg.sender, subAccountId);

updateAverageLiquidity(account);

AssetStorage storage assetStorage = eTokenLookup[address(this)];
AssetCache memory assetCache = loadAssetCache(...);

uint balance = assetStorage.users[account].balance;
require(balance >= amount, "insufficient balance");

assetStorage.users[account].balance = balance - amount;
assetCache.reserveBalance += amount;

// The one line that would have saved $197M
checkLiquidity(account);
}
```

One function call. That's it. The deeper structural fix would have been to cap the soft-liquidation discount so that even an artificially underwater position couldn't extract more than its true collateral — but the immediate patch was the missing solvency check.

The General Bug Class

This isn't really about donations. It's about **state-mutating external functions that bypass a global invariant check**. Every lending protocol has the same invariant: *after any user-triggered state change, that user's account must be solvent or the action reverts.* Skipping that check on "safe-looking" functions is the pattern.

Things that look safe but aren't:

Donating / burning your own balance (Euler)
- Transferring to a sub-account you also control
- "Rebalancing" functions that move collateral between vaults
- Adjusting collateral factor settings on a position with active debt
- Any callback during a flash operation that re-enters accounting

If you're building a lending market, the rule is simple: **every external function that touches a user's balance, collateral, or debt MUST end in a solvency check**. No exceptions for "the user is making themselves poorer" — because in the presence of liquidation incentives, making yourself poorer is profitable.

Defensive Patterns

**1. Modifier-enforced invariant**

```solidity
modifier checksLiquidity(address account) {
_;
require(isHealthy(account), "unhealthy after action");
}

function donateToReserves(uint amount) external checksLiquidity(msg.sender) {
_burnEToken(msg.sender, amount);
_creditReserves(amount);
}
```

**2. Cap liquidation discount at true LTV**

The attacker only profited because the discount exceeded the gap between collateral and debt. Bound the bonus to `min(maxBonus, collateralValue - debtValue)` so a fully self-inflicted underwater position pays out zero bonus.

**3. Disallow self-liquidation**

Require `liquidator != borrower` and also `liquidator's sub-accounts != borrower's sub-accounts`. This alone would have killed the attack — the bonus is fine if it goes to a third party who took real risk.

Audit Checklist

When we run a [manual audit](https://www.cryptohawking.com/audit/manual) on a lending protocol, the first grep is: list every external/public function, then for each one, confirm it terminates in a liquidity check or is provably read-only. The Euler bug would have surfaced in 10 minutes of that exercise. For early-stage protocols, the free [AI audit](https://www.cryptohawking.com/audit) catches the same pattern automatically — missing post-condition checks are exactly what static analysis is good at.

Closing Thought

Euler had been audited six times by reputable firms before this exploit. Six. The lesson isn't "audits don't work" — it's that audits look at the code that's there, not always at the code that should be there. A missing line is the hardest finding. Build your invariants into modifiers and let the compiler enforce them.

FAQ

Why did the soft-liquidation discount matter so much in this attack?

Euler's soft-liquidation scaled the bonus dynamically based on how underwater a position was — the more insolvent, the bigger the discount, up to 20%. This was designed to incentivize liquidators on genuinely distressed positions. But because the attacker could artificially push their own position to maximum insolvency via donateToReserves(), they triggered the maximum bonus on a self-liquidation. A fixed, smaller discount or a discount capped at the actual collateral-debt gap would have made the attack unprofitable even with the missing solvency check.

Were the auditors at fault for missing this?

Six firms audited Euler and none caught it, which tells you something about how subtle missing-check bugs are. Auditors review what's written, and donateToReserves looked benign — a user voluntarily reducing their own balance to top up reserves. The mental model 'giving away money can't hurt you' is wrong in lending protocols because debt stays constant. The bug class to internalize: any function that changes collateral, debt, or balance needs a post-condition liquidity check, regardless of intent.

Has this bug class appeared in other protocols since?

Yes, repeatedly in Euler forks and unrelated lending protocols. The pattern — a state-mutating function that bypasses the global solvency invariant — shows up whenever developers add 'helper' or 'admin-adjacent' functions late in development. Sturdy Finance (June 2023, $800K) and several smaller Compound forks have shipped variants. The donation primitive is gone but the structural mistake of trusting that 'this function can't make a position worse' keeps recurring.

Should every lending protocol disallow self-liquidation entirely?

It's a strong defensive default. There's no legitimate use case where a borrower benefits from liquidating themselves — the bonus is meant to compensate third parties for taking on risky debt. Blocking self-liquidation (including across sub-accounts and contracts the borrower controls) costs nothing and eliminates an entire attack surface. Most modern lending designs include this check. If you're forking older code, audit specifically for whether liquidator and borrower can share an owner.

What's the minimum-effort way to enforce the solvency invariant?

Use a modifier on every external state-changing function that ends with require(isHealthy(account)). Better: structure your code so accounting changes route through a single internal function that always finishes with the check, and forbid direct storage writes elsewhere. The Euler bug existed because donateToReserves wrote to storage directly without going through the standard path. Centralizing the invariant in one chokepoint means a new developer adding a new feature can't accidentally bypass it.

One Solidity tip + 1 case study per month

Euler Finance Hack: $197M Lost to a Donation-and-Liquidate Bug | Crypto Hawking