Lost
Came back
Attacked
Audit
Euler added donateToReserves (EIP-14) so users could give eTokens to the protocol's reserves. It lowered the donor's collateral but never ran the liquidity check every other balance-lowering function ends with, so an account could donate itself under water and be liquidated at the maximum discount by a second contract, which withdrew the collateral.
lendingliquidationflash loanupgrade (EIP-14)
Attacker deploys
Deploys two contracts: one builds the position (the violator), the other liquidates it.
Aave.flashLoan
Borrows DAI for one transaction. Flash loans are a normal feature; everything below happens before it must be repaid.
EToken.deposit
Deposits the DAI into Euler and receives eDAI, its collateral.
EToken.mint
Mints eDAI and dDAI together: Euler's built-in leverage, debt and collateral created as a pair. Repays part of the debt and mints again; Euler's own check says the account is healthy.
EToken.donateToReserves
Donates eDAI to the reserves. The account's collateral falls below its debt, yet the call succeeds.
Missing check. No checkLiquidity(account) after the donation: unlike withdraw, mint or transfer, this function never asks whether the donor is still solvent.
Liquidation.liquidate
The second contract liquidates the under-water account. The deeper under water, the larger the discount: it takes the eDAI collateral cheaply and only part of the debt.
EToken.withdraw
The liquidator withdraws the DAI behind its eDAI. The bad debt stays with the first account.
Aave.repay
Repays the flash loan and keeps the difference. The same was repeated for WBTC, stETH and USDC.
Fund flow, in order
Every function that lowered an account's collateral ended with checkLiquidity(account). The one added later did not, and the liquidation logic assumed no owner could push a healthy account under water on purpose.
The invariant that would have failed
After any call, an account that was healthy is still healthy, or the call reverts.
// a handler calls deposit, mint, repay, withdraw and donateToReserves in random order;// after every call, every account it touched must still be solventfunction invariant_accountsStaySolvent() public view { for (uint256 i; i < handler.actorCount(); i++) { address account = handler.actor(i); assertTrue(markets.isSolvent(account), "a call left an account under water"); }}