
Euler Finance Hack Replayed: the One Missing Health Check
15 min
On this page▾
TL;DR
- On 13 March 2023, Euler Finance lost about $197M. Every call the attacker made was a public Euler function, or an Aave flash loan, and none of them reverted.
- The bug was one missing line. Every Euler function that could weaken an account, like
withdrawandmint, ended withcheckLiquidity(account).donateToReserveslowered the donor's collateral and never ran that check. donateToReserveswas not part of the original code. It arrived later, as part of a fix for an earlier bug report.- The attacker donated their own collateral until their account was underwater, then liquidated it from a second contract. Euler's liquidation discount grows as the health factor falls, so the attacker bought their own bad position at the maximum 20% discount. In the DAI market alone, that emptied the pool: 38.9M DAI.
- We rebuilt Euler's DAI market in Foundry and replayed the attack locally. Our figures land within 0.1% of the real transaction. With the one missing line added, the attack reverts at step six.
- The check for your own protocol: every function that lowers collateral or raises debt ends with the health check, including the small ones added later as a fix.
Watch the replay
Watch the replay on YouTube: Euler Finance Hack Replayed: the One Missing Line Behind $197M.
The video (5:11) runs the attack and the fix on screen, with Euler's own code and the real test output. This article covers the same ground in text, with the code and the numbers side by side.
What happened in the Euler Finance hack?
The Euler Finance hack was a $197M exploit of the Euler v1 lending protocol on Ethereum, on 13 March 2023 (CertiK and BlockSec post-mortems). Every call the attacker made was one of Euler's public user functions, plus Aave's flash loan, and none of them reverted. The order was the attack: a flash loan, a deposit, two leveraged mints, a repayment, then a donation of their own collateral to the protocol's reserves, a liquidation of themselves, and a withdrawal.
The $197M is the total for the whole incident. This article follows one market, DAI, the way the video does, because the attack transaction for that market is public and the numbers can be checked line by line: transaction
0xc310a0affe2169d1f6feec1c63dbc7f7c62a887fa48795d327d4d2da2d6b111d on Ethereum mainnet.For how flash loans turn small flaws like this one into nine-figure losses across many protocols, see our flash loan attacks breakdown. This article stays on Euler, and on the one line.
How Euler v1 accounted for collateral and debt
Euler v1 tracked every position with two tokens per asset:
- Deposit DAI, get eDAI. eDAI is your collateral.
- Borrow, get dDAI. dDAI is your debt.
mintgives you both at once. Euler's own code calls it a "self-borrow": you receive eTokens and the matching dTokens in one call, borrowing against yourself.- Health = your collateral, weighted, divided by your debt. The weighting is a collateral factor; for a self-borrow in the same asset, Euler v1 used 0.95 (
Constants.sol). - Below 1, anyone can liquidate you.
Self-borrowing is what made the attack large. With
mint, an account can stack up hundreds of millions of collateral and debt that sit almost exactly on top of each other, staying just above a health of 1.The missing line: withdraw versus donateToReserves
Every function that weakens an account ends the same way
Here is
withdraw, lines 180 to 200 of Euler v1's contracts/modules/EToken.sol (from the euler-xyz/euler-contracts repository, read 8 Nov 2026):1function withdraw(uint subAccountId, uint amount) external nonReentrant {2 (address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER();3 address account = getSubAccount(msgSender, subAccountId);45 updateAverageLiquidity(account);6 emit RequestWithdraw(account, amount);78 AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);910 uint amountInternal;11 (amount, amountInternal) = withdrawAmounts(assetStorage, assetCache, account, amount);12 require(assetCache.poolSize >= amount, "e/insufficient-pool-size");1314 pushTokens(assetCache, msgSender, amount);1516 decreaseBalance(assetStorage, assetCache, proxyAddr, account, amountInternal);1718 checkLiquidity(account);1920 logAssetStatus(assetCache);21}
It sends you the tokens, lowers your balance, and then runs
checkLiquidity(account). If your health is now below 1, the whole transaction reverts. mint ends with the same line.donateToReserves lowers your balance and stops
Here is
donateToReserves, lines 359 to 386 of the same file:1function donateToReserves(uint subAccountId, uint amount) external nonReentrant {2 (address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER();3 address account = getSubAccount(msgSender, subAccountId);45 updateAverageLiquidity(account);6 emit RequestDonate(account, amount);78 AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);910 uint origBalance = assetStorage.users[account].balance;11 uint newBalance;1213 if (amount == type(uint).max) {14 amount = origBalance;15 newBalance = 0;16 } else {17 require(origBalance >= amount, "e/insufficient-balance");18 unchecked { newBalance = origBalance - amount; }19 }2021 assetStorage.users[account].balance = encodeAmount(newBalance);22 assetStorage.reserveBalance = assetCache.reserveBalance = encodeSmallAmount(assetCache.reserveBalance + amount);2324 emit Withdraw(assetCache.underlying, account, amount);25 emitViaProxy_Transfer(proxyAddr, account, address(0), amount);2627 logAssetStatus(assetCache);28}
It lets you give your eDAI to the protocol's reserves. It lowers your collateral balance, just like
withdraw. Read it to the end: there is no checkLiquidity. Your debt stays where it was, so after a large enough donation your account is underwater, and nothing stops the transaction.It arrived later, as a fix
donateToReserves was not in the original code. According to the whitehat who reported that earlier bug, the fix for that report is what introduced it, and Euler's CEO told The Block the change had been audited and approved through governance (The Block). The lesson is about process as much as code: a small function added after launch, as part of a fix, sat next to code that already had the right pattern and did not follow it. Our post-audit security piece covers why a fix needs the same review as the launch.The Euler attack, step by step
These are the nine steps of the DAI attack, from the attack transaction as CertiK and BlockSec analysed it:
| Step | What the attacker did | The attacking account after it |
|---|---|---|
| 1 | Flash loan of 30M DAI from Aave | (outside Euler) |
| 2 | Deposit 20M DAI into Euler | healthy |
| 3 | mint 200M: 200M of dDAI debt and 195.6M eDAI | healthy, checkLiquidity passes |
| 4 | Repay 10M of the debt | healthy |
| 5 | mint 200M again | healthy, checkLiquidity passes |
| 6 | Donate 100M eDAI to the reserves | underwater, nothing checked |
| 7 | A second attacker contract liquidates the first | liquidated at a 20% discount |
| 8 | The second contract withdraws 38.9M DAI, everything left in the pool | (pool empty) |
| 9 | Repay the flash loan | (outside Euler) |
After step six, the attacking account held about 310M eDAI of collateral against 390M dDAI of debt (BlockSec: "310M eDAI vs. 390M dDAI"). It was underwater on purpose, and no function had checked.
In step seven, the liquidator contract took all of that eDAI, about 310.93M by CertiK's figures, and only about 259.32M of the debt. It then withdrew the 38.9M DAI the pool held and repaid Aave. The rest of the debt was left in the protocol as bad debt.
Why the attack paid: a self-liquidation at the maximum discount
The donation created the bad debt. The liquidation discount turned it into profit.
Euler's liquidation discount grows the sicker the account is. In Euler v1's
Liquidation.sol (lines 91 to 98), the base discount is the reserves fee, 2%, plus (1 - health), capped at a maximum of 20%. A small booster of up to 2.5% sits in between; the 20% cap applies either way.That is good design on its own. The worse a position gets, the bigger the reward, and the faster liquidators show up to close it.
The attacker controlled both sides. They made their own account as sick as they wanted with the donation, then bought it from themselves, from a second contract, at the maximum discount. In our rebuild, health after the donation is 0.77: 2% plus 23% is 25%, so the discount hits the 20% cap. CertiK's analysis confirms the real liquidation "gave a 20% discount". That is a self-liquidation: the attacker is the borrower and the liquidator at once, and the discount that was meant to pay a stranger for cleaning up risk paid the attacker for creating it.
Replaying the Euler exploit in Foundry
What we rebuilt
We rebuilt Euler's DAI market as a minimal standalone Foundry project and ran the attack as a test. Nothing touches a live network: the test runs on Foundry's local in-memory chain.
The rebuild uses the same nine steps, the same amounts and the same starting pool as the real transaction: 8,904,507 DAI in Euler before the deposit, CertiK's figure. It uses Euler v1's own constants: the 0.95 self-collateral factor (
Constants.sol), the 2% reserves fee and the 20% maximum discount (Liquidation.sol).It is simplified, and the numbers below should be read that way: one asset, a fixed eToken rate of 1.0225 DAI per eDAI, no interest and no oracle. The code shown above is Euler's own
EToken.sol, byte for byte; the test runs against our rebuild of it.The output
This is the real output of
forge test --match-test test_replay_theHack -vv against the rebuild:1[PASS] test_replay_theHack() (gas: 3757892)2Logs:3 Euler DAI pool before: 8,904,507 DAI4 [1] Flash loan 30,000,000 DAI5 [2] Violator deposits 20,000,000 DAI6 -> 19,559,902 eDAI7 [3] Violator mints (borrows from itself) 200,000,000 dDAI of debt8 collateral 215,158,924 eDAI | debt 200,000,000 dDAI | health 1.049 [4] Violator repays 10,000,000 DAI10 [5] Violator mints again 200,000,000 dDAI of debt11 collateral 410,757,946 eDAI | debt 390,000,000 dDAI | health 1.0212 [6] Violator donates to reserves 100,000,000 eDAI13 collateral 310,757,946 eDAI | debt 390,000,000 dDAI | health 0.7714 [7] Liquidator liquidates the violator, 20% discount15 gets 310,757,946 eDAI, takes on 259,283,999 dDAI16 [8] Liquidator withdraws the whole pool 38,904,507 DAI17 [9] Flash loan repaid 30,027,000 DAI18 attacker keeps 8,877,507 DAI19 Euler DAI pool after: 0 DAI
Steps one to five keep health above 1 (1.04, then 1.02 in the rebuild), so every check passes. Step six drops it to 0.77 with no check. Step seven pays the 20% discount. The pool goes from 38,904,507 DAI to zero.
How close the rebuild is to the real transaction
| Figure | Real transaction (CertiK) | Our rebuild | Difference |
|---|---|---|---|
eDAI from the first mint | 195.6M | 195,599,022 | same to the digit shown |
| eDAI the liquidator receives | 310,930,612 | 310,757,946 | −0.06% |
| dDAI the liquidator takes on | 259,319,058 | 259,283,999 | −0.01% |
| DAI withdrawn from the pool | 38,904,507 (8,904,507 + 20M + 10M) | 38,904,507 | exact |
The attacker's profit from this one pool in the rebuild is 8,877,507 DAI: the 38,904,507 DAI withdrawn, minus the 30M flash loan and Aave V2's 0.09% fee (27,000 DAI). That figure is our rebuild's; CertiK and BlockSec do not state a DAI-pool profit, so treat it as "about 8.9M".
The fix: one line, the same line withdraw already had
The fix the post-mortems point to is the line
withdraw and mint already had. CertiK: "checkLiquidity() is missing". BlockSec names "the lack of insolvency check" in donateToReserves. At the end of the donation, check the donor:1 assetStorage.users[account].balance = encodeAmount(newBalance);2 assetStorage.reserveBalance = assetCache.reserveBalance = encodeSmallAmount(assetCache.reserveBalance + amount);34 emit Withdraw(assetCache.underlying, account, amount);5 emitViaProxy_Transfer(proxyAddr, account, address(0), amount);67 checkLiquidity(account); // the line withdraw already had89 logAssetStatus(assetCache);10}
This is the fix as the post-mortems describe it, not a patch Euler shipped: Euler v1 was not re-deployed with it.
On the fixed rebuild, the same test with the same attacker gives this (
forge test --match-test test_fullAttack_withTheFix_reverts -vv):1[PASS] test_fullAttack_withTheFix_reverts() (gas: 3672549)2Logs:3 [1] Flash loan 30,000,000 DAI4 [2] Violator deposits 20,000,000 DAI5 -> 19,559,902 eDAI6 [3] Violator mints (borrows from itself) 200,000,000 dDAI of debt7 collateral 215,158,924 eDAI | debt 200,000,000 dDAI | health 1.048 [4] Violator repays 10,000,000 DAI9 [5] Violator mints again 200,000,000 dDAI of debt10 collateral 410,757,946 eDAI | debt 390,000,000 dDAI | health 1.0211 [6] Violator donates to reserves -> reverted: e/collateral-violation, and the whole attack with it12 Euler DAI pool after the attempt: 8,904,507 DAI
Steps one to five pass. Step six reverts with
e/collateral-violation, the whole transaction reverts with it, and the pool keeps all of its 8,904,507 DAI.How to check your own lending protocol
The Euler bug is easy to see once you know where it is. The hard part is that it was one function among many that did the right thing. Here is the check we would run on any lending protocol:
- List every function that can lower someone's collateral or raise their debt.
withdrawandborrow, but also the quiet ones: a donation, a transfer of collateral tokens, a rewards claim, anything added later as a small fix. - Make sure every one of them ends with the health check. Not most of them. Every one, including the ones that look like they only help the protocol.
- Ask whether a user can make their own position worse on purpose, then liquidate it themselves. If an account can choose its own health, it can choose its own discount.
- Look at how your liquidation discount behaves. If it grows as health falls, question three matters even more, because the sickest account pays the best.
- Give every change after launch the same review as the launch.
donateToReserveswas a small addition in a fix.
An invariant test is one way to catch this class of bug without knowing where it is: after any sequence of user calls, no account that started healthy should end below a health of 1 unless prices moved. Our guide to Foundry invariant testing shows how to write one, and five ways a lending vault ends up with bad debt covers the other side of the same risk: collateral valued at a price that is not real.
The checks from this article are in our free lending security checklist.
Sources
All checked 8 Nov 2026.
- Euler v1 source,
euler-xyz/euler-contracts(master):EToken.sol,Liquidation.sol,Constants.sol - CertiK, Euler Finance Incident Analysis
- BlockSec, Euler Finance Incident: The Largest Hack of 2023
- The Block, on how donateToReserves was added
- The DAI attack transaction:
0xc310a0affe2169d1f6feec1c63dbc7f7c62a887fa48795d327d4d2da2d6b111d(Ethereum mainnet, 13 March 2023)
FAQ
1. How was Euler Finance hacked?
The attacker used only Euler v1's own public functions, plus a 30M DAI flash loan from Aave. They built a large leveraged position with
mint, then called donateToReserves to give 100M eDAI of their own collateral to the protocol. That function lowered their collateral without checking their account's health, so the account went underwater: about 310M eDAI against 390M dDAI. A second contract then liquidated it at Euler's maximum 20% discount, withdrew the 38.9M DAI left in the pool, and repaid the flash loan. The whole incident cost Euler about $197M on 13 March 2023.2. What was the bug in donateToReserves?
donateToReserves lowered the caller's eToken balance, their collateral, but did not end with checkLiquidity(account), the health check that withdraw and mint both run. Every other Euler function that could weaken an account reverted if it left the account below a health of 1. This one did not, so a user could push their own account underwater on purpose. The fix the post-mortems point to is that one missing line.3. Why did a self-liquidation make the attacker money?
Euler's liquidation discount grows as an account's health falls: 2% plus (1 − health), capped at 20%. That rewards liquidators for closing the riskiest positions quickly. But the attacker controlled both the unhealthy account and the liquidator. By donating collateral, they chose their own health, low enough to reach the 20% cap, and then bought their own position at that discount. The liquidator received about 310.93M eDAI while taking on only about 259.32M of debt, enough to withdraw the whole DAI pool and repay the flash loan with DAI left over.
4. How much did Euler lose, and how much came from the DAI pool?
About $197M in total, according to CertiK and BlockSec. The DAI attack transaction withdrew 38.9M DAI, all that was left in Euler's DAI pool. In our rebuild of that market, the attacker keeps 8,877,507 DAI after repaying the 30M flash loan and Aave's 0.09% fee; that profit figure is from our rebuild, not from the post-mortems.
5. How do I check my lending protocol for the same bug?
List every function that can lower a user's collateral or raise their debt, including small ones added later as fixes, and confirm each one ends with the health check. Then ask whether a user can make their own position worse on purpose and liquidate it themselves, especially if your liquidation discount grows as health falls. An invariant test that runs random sequences of user calls and asserts no healthy account ends below a health of 1 will catch a missing check like Euler's.
Glossary
| Term | Definition |
|---|---|
| Health Factor | A lending position's risk-adjusted collateral divided by its debt; below 1, the position can be liquidated. |
| Liquidation Discount | The share of a position's collateral value a liquidator keeps as a reward for repaying its debt. |
| Self-Liquidation | Liquidating a position you control from another account you also control, to collect the liquidation reward yourself. |
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →
Keep reading


