All audits
FailedEthereumIndependent

Euler Finance Donation (2023)

Smart contract security audit by TierZero · 2023-10-15

Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.

Findings summary

1
Critical
2
High
1
Medium
1
Low
1
Informational

What the protocol did

Euler Finance was a permissionless lending market on Ethereum, structurally similar to Compound and Aave but with a wider risk model: any ERC-20 could be listed, collateral tiers were split into "collateral" and "non-collateral" assets, and the protocol supported undercollateralized-looking positions through a feature that let borrowers hold more debt than a naive loan-to-value check would suggest, provided the account passed an internal liquidity (health) check. Deposits minted interest-bearing eTokens; borrows minted debt-tracking dTokens. Liquidators could seize an unhealthy account's collateral at a discount that scaled with how far underwater the position was — a design borrowed from other money markets, but with a steeper discount curve than most.

On March 13, 2023, an attacker drained roughly $197 million in DAI, WBTC, stETH, and USDC-denominated assets across a sequence of transactions in a single block-adjacent window. Euler paused the protocol immediately. Most of the funds were returned by the attacker in the following weeks after on-chain negotiation and public pressure, but the mechanics of the exploit are a textbook case for anyone doing smart contract audits on lending protocols.

The vulnerability

The root cause was a single missing check in one function: donateToReserves. This function let an eToken holder donate their own eTokens to the protocol's reserves, reducing their collateral balance. Every other balance-mutating path in Euler's eToken contract — withdraw, transfer, mint against debt — ended with a call to the internal solvency check (checkLiquidity / checkAccountLiquidity in the deployed code). donateToReserves did not.

Illustrative pattern (simplified, not the literal deployed bytecode):

// Vulnerable pattern
function donateToReserves(uint subAccountId, uint amount) external {
    address account = getSubAccount(msg.sender, subAccountId);
    _decreaseBalance(account, amount);      // burns eTokens
    reserveBalance += amount;
    // MISSING: checkLiquidity(account);
    emit RequestDonate(account, amount);
}

function withdraw(uint subAccountId, uint amount) external {
    address account = getSubAccount(msg.sender, subAccountId);
    _decreaseBalance(account, amount);
    _transferOut(account, amount);
    checkLiquidity(account);                // present here — the asymmetry is the bug
}

Because donation burned collateral without triggering a solvency check, a user could push their own account into a deeply undercollateralized state on purpose, in a single transaction, with no revert. That by itself is just a self-inflicted bad debt — the second half of the bug is what made it profitable: Euler's liquidation discount grew as the health factor deteriorated, with no floor calibrated to what a single-block, self-triggered insolvency could look like. An account driven far enough underwater produced a liquidation bonus large enough to more than repay the debt used to get there. This is the same family of bug as approval and delegation logic that assumes a downstream check will catch what an earlier step skipped — the kind of gap covered in our note on Permit2-style gasless approval risk; the lesson generalizes past approvals to any state-mutating entry point that shares a solvency invariant with siblings but doesn't enforce it locally.

How the exploit worked

  1. The attacker flash-borrowed a large amount of DAI (from Aave) to seed initial capital.
  2. Deposited into Euler and borrowed against it, then repeated the mint/borrow cycle to build a large, highly leveraged position across two related sub-accounts.
  3. Called donateToReserves from the "victim" sub-account, burning most of its eToken collateral while leaving its dToken debt untouched — with no health check firing, the transaction succeeded and the account became severely insolvent.
  4. Called liquidate from the second, healthy sub-account against the now-insolvent one. Because the discount curve rewarded severity, the liquidator sub-account received collateral worth far more than the debt it repaid.
  5. Repeated the cycle across multiple collateral types (WBTC, stETH, USDC markets) and unwound the flash loans, netting the difference as profit.
  6. Moved proceeds, later returning the bulk of the funds after negotiation — a detail relevant to the outcome, not to the vulnerability class itself.

No oracle was manipulated, no external price feed was touched, and no reentrancy was involved. The entire exploit was Euler's own accounting logic used exactly as written, against itself.

How an audit catches this

This class of bug is found by treating solvency as a protocol-wide invariant rather than a per-function feature, then testing every function that mutates balances against it, not just the ones an author remembers to gate. Concretely, we run:

  • Function inventory against invariants. List every entry point that changes an account's collateral or debt balance, then verify each one enforces (or explicitly and intentionally skips, with a documented reason) the same solvency check as its siblings. A donate, sweep, or reserve-transfer function that skips it should be a flagged finding by default, not an oversight caught later.
  • Differential review across audit rounds. donateToReserves was added after Euler's original audits and never got the same scrutiny as the base lending logic — a common failure mode we check for explicitly in code review and audit engagements covering incremental changes, not just full rewrites.
  • Invariant / property-based fuzzing. An invariant test asserting "no function call can leave healthFactor(account) < MIN without reverting, except intentional liquidation paths" would have caught this in minutes of fuzzing, the same way we'd validate liquidation and solvency logic through property tests or a formal model, as discussed in our piece on formal verification of trading program logic.
  • Liquidation discount stress-testing. Simulate self-triggered insolvency at the extreme end of the discount curve and confirm the resulting liquidator payout never exceeds a bounded multiple of the debt repaid.

Remediation

Euler's post-mortem fix added the missing liquidity check to donateToReserves and, more broadly, the relaunched Euler v2 moved to isolated, permissionless vaults with narrower blast radius per market and a modular risk framework reviewed independently of any single monolithic contract. The general remediation pattern for any protocol with a shared solvency invariant: centralize the check in a modifier or internal function called by every balance-mutating path, write an invariant test that fails the build if a new function forgets it, and cap liquidation bonuses so a single-transaction, self-inflicted insolvency can never be worth more than the collateral actually at risk.

If your protocol has any function that moves collateral or debt without funneling through a shared solvency check, that is exactly the gap this incident exploited — worth having a second set of eyes on before mainnet, via a smart contract audit.

Need your contracts audited?

Manual review + tooling across TON, Solana and EVM — certificate and full report included.

Get an audit