3.10.8 Euler Finance (March 2023)
The Euler Finance exploit drained approximately $197 million from a sophisticated, multiply-audited lending protocol in a single attack on March 13, 2023. The bug was small — a single missing function call. The mechanism was elegant — the attacker deliberately put themselves into liquidation, then liquidated themselves at a profit. The aftermath was unprecedented — the attacker returned all of the stolen funds over the following weeks following public negotiations and a posted Ethereum message reading "Jesus is the way."
The case closes Section 3.10 because it demonstrates that the bugs do not get easier to find as the security industry matures. Euler had been audited by six different firms. The protocol had been live for over a year, used by sophisticated DeFi participants, and integrated into multiple yield strategies. The team was technically strong. The audits were performed by reputable firms. The bug was missed by all of them — a single function that should have included checkLiquidity() and didn't, while every comparable function in the same codebase did.
The Euler case is also a clean illustration of how flash loans plus a single logic error combine to produce nine-figure losses. Section 3.10.3 (bZx) established flash loans as a capital primitive; six years later, Euler demonstrated that the same primitive could exploit an entirely different vulnerability class — not oracle manipulation, but a missing solvency check on a function the attacker would never legitimately call.
Section 3.8.4 (Access Control Failures) and Section 3.8.5 (Oracle & Price Manipulation, including flash-loan-enabled patterns) both draw on this case. The deeper lesson — every state-changing function must enforce the protocol's invariants, even functions that "no rational user would call against their own interests" — is one of the most generalizable in this section.
Context
Euler Finance was a permissionless lending protocol on Ethereum mainnet. It positioned itself as a more flexible alternative to Compound and Aave, with several novel features:
- Tiered listing system — any token could be listed as collateral or borrowable, with different risk tiers determining what could be borrowed against what
- Soft liquidation — instead of liquidating a fixed proportion of a position when health dropped below 1, the liquidation amount scaled with how unhealthy the position was
- Self-borrow / leverage — users could mint eTokens (interest-bearing collateral receipts) backed by dTokens (debt receipts) without first depositing the underlying — useful for amplifying leverage in a single transaction
- donateToReserves — a public function letting any user voluntarily transfer their eToken balance to the protocol's reserve, reducing their position size
At the time of the exploit:
- Total Value Locked: ~$300M across multiple pools (DAI, USDC, stETH, WBTC, others)
- Audits completed: 6 separate firms had audited some portion of the protocol over its lifetime
- Live since: December 2021 (over 15 months in production at the time of the exploit)
- Codebase: ~5,000 lines of Solidity, modular architecture with separate modules for risk, liquidation, governance, etc.
The exploit took place on March 13, 2023. Across multiple transactions targeting different pools, the attacker drained approximately:
- 8.9M DAI
- 34.2M USDC
- 8,877 stETH
- 849 WBTC
Total at then-current prices: approximately $197M.
The bug was in production for the entire 15+ months Euler had been live. No audit had caught it. The attacker had identified what six audit firms missed.
The Architecture
Euler used a token system inspired by Compound's cTokens but extended:
- eTokens — interest-bearing receipt tokens issued when a user deposits an asset. Holding eDAI means you have a claim to DAI in the protocol that earns interest.
- dTokens — debt receipt tokens issued when a user borrows. Holding dDAI means you owe DAI to the protocol that accrues interest.
A user's "health score" was calculated as roughly:
healthScore = (sum of eToken value × LTV ratios) / (sum of dToken value)
A health score above 1 meant the user was solvent. Below 1 meant they were eligible for liquidation. At exactly 1, "soft liquidation" — Euler's distinctive feature — could begin.
Soft Liquidation
Unlike Compound's fixed liquidation incentive (typically a 5-8% bonus to the liquidator), Euler's soft liquidation scaled the discount with the unhealthiness of the position:
- Position barely below 1: small discount (~2% bonus to liquidator)
- Position significantly below 1: larger discount (up to 20%)
- Position in "bad debt" territory: liquidator can claim up to 75% of collateral
The intent was reasonable. Healthy-but-failing positions should not be aggressively liquidated (too punitive on the borrower); badly-failing positions need aggressive liquidation (the protocol needs liquidators to be motivated). The scaled discount was meant to incentivize liquidators in proportion to the protocol's risk.
The bug-enabling consequence: if an attacker could create a position whose health score was significantly below 1, the liquidation discount would be large enough that the liquidator received substantially more value than they spent. If the attacker themselves could be both the violator (the unhealthy borrower) and the liquidator, they could capture this profit at the protocol's expense.
The donateToReserves Function
Euler included a function letting users voluntarily reduce their position by donating eTokens to the protocol's reserve:
function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
Asset storage assetStorage = ...;
AssetCache memory assetCache = ...;
address account = getSubAccount(msg.sender, subAccountId);
updateAverageLiquidity(account);
// Calculate new balance after donation
AssetStorage memory userAssetStorage = assetStorage.users[account];
uint origBalance = userAssetStorage.balance;
uint newBalance;
if (amount == type(uint).max) {
amount = origBalance;
newBalance = 0;
} else {
require(origBalance >= amount, "insufficient");
newBalance = origBalance - amount;
}
// Update state
assetStorage.users[account].balance = encodeAmount(newBalance);
assetStorage.reserveBalance = encodeSmallAmount(
decodeSmallAmount(assetStorage.reserveBalance) + amount
);
// BUG: no call to checkLiquidity(account)
// Every other transfer/burn function calls this; donateToReserves does not.
emit Donate(account, amount);
}
The function was rare for a public function — its caller is voluntarily giving up an asset for no return. The intuition the developers seem to have had: no rational user would donate their own collateral while in debt, because doing so would harm them. The function therefore didn't need a solvency check.
This intuition was wrong on three counts. First, the function being public means any caller can invoke it, not just rational ones. Second, "would harm them" assumes the caller has no other strategy that profits from being harmed by donation — which is exactly what the attacker had. Third, every other state-changing function in the same codebase enforced checkLiquidity() defensively, regardless of whether the operation seemed self-harming. The missing check was an inconsistency, and inconsistencies in security-critical code are bugs.
The Attack
The attacker used a flash loan to amplify the position substantially, deliberately put themselves into deep insolvency via donateToReserves, then self-liquidated to extract the protocol's collateral at a deep discount. The flow:
Step 1: Flash Loan
The attacker borrowed 30M DAI from Aave's flash loan facility. No collateral required; just had to be repaid in the same transaction.
Step 2: Deposit and Leverage
The attacker deposited 20M DAI into Euler, receiving ~19.6M eDAI. They then used Euler's self-borrow feature to mint additional leverage:
- Minted ~195.6M eDAI (collateral)
- Was issued ~200M dDAI (corresponding debt)
After this step:
- Collateral (eDAI): ~215.2M
- Debt (dDAI): ~200M
- Health score: ~1.09 (comfortably solvent at this point)
The leverage came from Euler's mint function, which allowed users to mint up to 19x their initial collateral in eTokens, with matching dTokens recording the debt. The intent was to let users efficiently take leveraged positions without round-trip swaps; the side effect was that the attacker now had an enormous total position size from a small initial deposit.
Step 3: Repay Some Debt
The attacker used the remaining 10M DAI from the flash loan to repay 10M of the dDAI debt, reducing the debt without affecting the eDAI collateral.
After this step:
- Collateral (eDAI): ~215.2M
- Debt (dDAI): ~190M
- Health score: ~1.09 (still solvent)
Step 4: Mint More Leverage
The attacker minted another round of leverage:
- Minted an additional ~195.6M eDAI
- Was issued an additional ~200M dDAI
After this step:
- Collateral (eDAI): ~410.9M
- Debt (dDAI): ~390M
- Health score: ~1.02 (technically solvent, but just barely)
Step 5: The Donation
This is the move that exploited the bug. The attacker called donateToReserves(0, 100_000_000 ether), donating 100M eDAI to the protocol's reserve.
donateToReserves updated the attacker's eDAI balance from 410.9M to 310.9M. It did not call checkLiquidity(). Had it done so, the call would have reverted — because after the donation:
- Collateral (eDAI): ~310.9M
- Debt (dDAI): ~390M
- Health score: ~0.80 (deeply insolvent)
The attacker had voluntarily turned a healthy position into one that would have any liquidator's eye. The protocol now believed the attacker was a violator with significant bad debt.
Step 6: Self-Liquidate
The attacker deployed a second contract — the "liquidator" — and used it to liquidate their first contract (the "violator"). Because the violator's health score was 0.80, the soft liquidation mechanism was in the deeply-discounted regime. The liquidator received:
- 310.9M eDAI (the violator's entire eToken balance)
- Had to take on 259.3M dDAI in debt (the discount: the liquidator received more eDAI value than they took on dDAI debt)
This left the liquidator with:
- Collateral (eDAI): 310.9M
- Debt (dDAI): 259.3M
- Health score: comfortably above 1 (the liquidator was solvent)
Step 7: Withdraw and Repay
With the liquidator now holding excess collateral over debt, the attacker withdrew underlying DAI by burning eDAI tokens. They could withdraw the full available DAI balance of the pool — approximately 38.9M DAI — before the pool ran dry.
Of that 38.9M:
- ~30M went to repaying the Aave flash loan (with interest)
- ~8.9M was pure profit
Step 8: Repeat
The attacker then replicated the same sequence against other pools: USDC, stETH, and WBTC. Each pool had the same bug (donateToReserves was a generic function on the eToken contract, used across all asset pools). Each pool was drained similarly.
Total drained across all pools: approximately $197M.
The attack required less than an hour of execution time on-chain. Sky Mavis-style detection delay was not the issue (in fact, the protocol was paused within hours of the first transaction); the issue was that there was no on-chain mechanism that could prevent a single transaction sequence from draining a pool, even after the attack pattern was visible in earlier transactions.
Vulnerable Code
The smoking-gun comparison is between donateToReserves and every other state-changing function on the EToken contract. The burn function, for example, performed the same conceptual operation (removing eToken balance from a user) and did check liquidity:
// EToken.burn — properly checks liquidity
function burn(uint subAccountId, uint amount) external nonReentrant {
address account = getSubAccount(msg.sender, subAccountId);
// ... state updates ...
assetStorage.users[account].balance = encodeAmount(newBalance);
checkLiquidity(account); // <-- this check exists here
emit Burn(account, amount);
}
// EToken.donateToReserves — missing the same check
function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
address account = getSubAccount(msg.sender, subAccountId);
// ... state updates ...
assetStorage.users[account].balance = encodeAmount(newBalance);
assetStorage.reserveBalance = ...;
// BUG: no checkLiquidity(account) here
emit Donate(account, amount);
}
The two functions are structurally identical for the purposes of solvency checking. Both reduce the user's eToken balance. Both could move the user into liquidation territory if the balance reduction is large enough. One enforced the invariant; the other didn't.
The fix that Euler deployed post-exploit was exactly one line added to donateToReserves:
function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
// ... same as before ...
assetStorage.users[account].balance = encodeAmount(newBalance);
assetStorage.reserveBalance = ...;
checkLiquidity(account); // ✅ the missing line
emit Donate(account, amount);
}
A single function call. Six audits had missed it.
The Aftermath
Within hours of the exploit, the Euler team paused the protocol via its admin keys. The on-chain forensics were rapid. The exploit transactions were unambiguous; the bug was identified within hours; the attacker's wallet was being tracked in real time.
The Euler team posted on-chain messages and Twitter announcements directly to the attacker, offering a 10% bounty on returned funds and indicating they would pursue criminal prosecution if funds were not returned. Within days, on-chain message exchanges began. The attacker — who later turned out to be a 20-year-old Argentinian named Federico Jaime — initially sent ambiguous messages, including one that read "Jesus is the way."
Over the following three weeks, the attacker negotiated with the Euler team and gradually returned funds. By April 4, 2023, essentially all of the stolen funds had been returned to Euler. The actual realized user loss was zero.
The Euler-Jaime negotiations were not as smooth as the Poly Network Mr. White Hat case. There were periods where the attacker went quiet; there were periods where funds were sent to other addresses for unclear reasons; the eventual return was the result of sustained effort by the team plus law-enforcement pressure (Jaime was being publicly identified as the suspect by independent researchers tracing the funds). But the outcome was the same as Poly Network's: full recovery.
The protocol was paused for several months while a comprehensive review and rebuilding occurred. Euler v2 launched in 2024 with substantially revised architecture, and the team funded a substantial public bounty for finding remaining issues.
Root Cause
The Euler exploit had several compounding causes:
1. Missing invariant enforcement on a state-changing function (Section 3.8.4). The proximate cause: donateToReserves was missing the checkLiquidity(account) call that every other equivalent function on the contract included. This is the canonical "incomplete protection across the API surface" bug — the invariant was enforced almost everywhere, and the gap at one specific function was the entire vulnerability.
2. "No rational user would do this" as an implicit security argument. The developers' apparent intuition was that no user would harm themselves via donation. The intuition was wrong: an attacker who has another strategy to profit from being harmed at this step has no reason not to call the function. Public functions are always called by adversaries; the only question is whether the adversary has found a profitable way to do so.
3. Soft liquidation creating attacker-favorable economics (Section 3.8.5). Euler's soft liquidation mechanism — which awarded large discounts to liquidators of deeply-insolvent positions — was the amplifier that turned the bug into a $197M loss. Without the scaled discount, a self-liquidation would have produced at most a small profit. With it, deeply-insolvent self-liquidation extracted substantial value from the pool. The mechanism was designed to incentivize legitimate liquidators; it incentivized this attacker just as effectively.
4. Self-liquidation not prevented. A reasonable defensive design would prevent a single account (or controlled set of accounts) from being both the violator and the liquidator in the same liquidation event. Euler had no such constraint. Self-liquidation is rare in legitimate use cases; preventing it would have eliminated the attack pattern entirely while costing very little in legitimate functionality.
5. Flash loans amplify what would otherwise be a small bug. A user with $10M of their own capital might have profited modestly from this same bug. A user with $30M of flash-loaned capital amplified the same logic into a $197M loss. Section 3.10.3 (bZx) established this dynamic; Euler reinforced it. Any bug that produces a per-dollar profit greater than the cost of flash loan capital is exploitable at arbitrary scale.
6. Six audits did not catch this. This is the most uncomfortable root cause for the security industry generally. The bug was a single missing function call, in a function whose security implications were straightforward, in a codebase that was small enough for thorough review. The reviewers missed it. The lesson is not that audits are useless — they catch many bugs — but that they have a known false-negative rate that protocols must plan around.
Lessons
The Euler exploit produced lessons that built on several earlier cases in this section:
1. Every state-changing function must enforce all relevant invariants. Not "most." Not "the ones that obviously could break the invariant." Every state-changing function — including ones that seem self-harming, ceremonial, or aesthetic. The discipline is to write the invariant check as a defensive default, then prove it's unnecessary for any function that omits it, rather than the reverse.
2. Symmetric enforcement across an API. When a contract has multiple functions that perform conceptually similar operations, they should enforce the same invariants. Asymmetric enforcement — where most functions check X but one doesn't — is almost always a bug. Modern audit practice (Section 3.9) specifically looks for this kind of asymmetry.
3. Public functions must be analyzed adversarially. Any reasoning of the form "no rational user would call this in a harmful way" needs to be tested against "what if the user has profited from being harmed at this step?" Public functions are the contract's adversarial interface; assumptions about rational caller behavior do not survive there.
4. Soft / continuous incentive curves are attack-amplifier mechanisms. Euler's soft liquidation was a sophisticated design that worked correctly for its intended use case and amplified the attack's profitability. Continuous incentive curves — where the reward grows with the unhealthiness of the position, the slippage of a trade, the volatility of a price — are valuable but require explicit analysis of who profits at the extreme end of the curve. Section 3.11 (Advanced Contract Security) covers incentive-curve attacks more deeply.
5. Self-liquidation should be considered as a potential attack pattern. Any liquidation mechanism that doesn't prevent the violator and liquidator from being the same party — directly or via flash-loaned-and-deployed contracts — is exposed to the Euler pattern. The fix is either to prevent self-liquidation explicitly, or to ensure no economic benefit can be extracted from it.
6. Flash loans remain a dangerous amplifier for any DeFi bug. Section 3.10.3 (bZx) introduced this lesson in 2020. Euler reaffirmed it in 2023. Any DeFi protocol must assume that an attacker can borrow tens of millions of dollars for a single transaction at near-zero cost, then use that capital to amplify any logic bug they find. The threat model is mandatory; the historical pattern of "we'll think about flash loans later" is not viable.
7. Multiple audits are not multiple opportunities to catch the same bug. Auditors reviewing the same codebase tend to look for the same patterns and miss the same patterns. Six audits on Euler did not produce six independent chances to catch this bug; they produced largely-correlated reviews with similar blind spots. Genuine defense-in-depth in auditing requires reviewers with diverse backgrounds, methodologies, and adversarial assumptions — not just multiple firms reviewing the same code with similar approaches.
8. The "fund return" outcome should not be relied upon. Like Poly Network and Wormhole, Euler ended with all funds returned. Three of the four largest case studies in this section (Poly, Wormhole-via-Jump, Euler) ended this way. This might seem like a pattern, but the cases differ in important ways: Poly's attacker was a Mr. White Hat acting on principle, Wormhole's reimbursement came from a wealthy investor's reserves, and Euler's recovery required sustained negotiation under law enforcement pressure on an identifiable individual. None of these mechanisms is reliably available to a future exploited protocol. Security design must assume funds are gone if stolen.
Modern Reproduction
A simplified version of the pattern in modern Solidity:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableLending {
mapping(address => uint256) public collateral; // eToken balance
mapping(address => uint256) public debt; // dToken balance
uint256 public constant LIQUIDATION_THRESHOLD = 100; // 1.0 in BPS units of 100
function _healthScore(address user) internal view returns (uint256) {
if (debt[user] == 0) return type(uint256).max;
return (collateral[user] * LIQUIDATION_THRESHOLD) / debt[user];
}
function _checkLiquidity(address user) internal view {
require(_healthScore(user) >= LIQUIDATION_THRESHOLD, "insolvent");
}
function deposit(uint256 amount) external { /* ... */ collateral[msg.sender] += amount; }
function borrow(uint256 amount) external {
debt[msg.sender] += amount;
_checkLiquidity(msg.sender);
}
// Correct: enforces liquidity check
function withdraw(uint256 amount) external {
require(collateral[msg.sender] >= amount);
collateral[msg.sender] -= amount;
_checkLiquidity(msg.sender);
}
// BUG: no _checkLiquidity call
function donateToReserves(uint256 amount) external {
require(collateral[msg.sender] >= amount);
collateral[msg.sender] -= amount;
// missing: _checkLiquidity(msg.sender);
}
// Liquidation with deep-discount soft logic
function liquidate(address violator) external {
require(_healthScore(violator) < LIQUIDATION_THRESHOLD, "still solvent");
uint256 discount = _liquidationDiscount(_healthScore(violator));
uint256 effectiveDebt = (debt[violator] * (100 - discount)) / 100;
// Liquidator takes violator's collateral and debt
collateral[msg.sender] += collateral[violator];
debt[msg.sender] += effectiveDebt;
collateral[violator] = 0;
debt[violator] = 0;
_checkLiquidity(msg.sender); // liquidator must remain solvent
}
function _liquidationDiscount(uint256 healthScore) internal pure returns (uint256) {
if (healthScore >= 100) return 0;
if (healthScore <= 75) return 20; // 20% discount for deeply insolvent
return (100 - healthScore) * 20 / 25; // linear ramp
}
}
A Foundry test demonstrating the self-liquidation attack:
function test_EulerPattern_selfLiquidationProfit() public {
VulnerableLending pool = new VulnerableLending();
address violator = makeAddr("violator");
address liquidator = makeAddr("liquidator");
// Set up: violator has leveraged position
vm.startPrank(violator);
pool.deposit(1000); // mocked underlying deposit
pool.borrow(900);
vm.stopPrank();
// The buggy donation: violator donates collateral, becoming insolvent
vm.prank(violator);
pool.donateToReserves(300); // collateral: 700, debt: 900 → health = 78
assertEq(_healthScore(violator), 77); // approximately; insolvent
// The liquidator (controlled by same attacker) liquidates with deep discount
vm.prank(liquidator);
pool.liquidate(violator);
// Liquidator now holds violator's collateral with reduced effective debt
// Profit = collateral seized - effective debt taken on
}
The fix — adding _checkLiquidity to donateToReserves — would make the donation revert at the point where the violator becomes insolvent:
function donateToReserves(uint256 amount) external {
require(collateral[msg.sender] >= amount);
collateral[msg.sender] -= amount;
_checkLiquidity(msg.sender); // ✅ reverts if donation would cause insolvency
}
One line. That is, again, all that the fix required.
Closing Observations on Section 3.10
The eight case studies in this section span seven years of smart contract security: from The DAO (June 2016) to Euler Finance (March 2023). The trajectory:
- 2016: Reentrancy in a single-asset contract → $60M
- 2017: Unprotected initialization in upgradeable wallets → $310M (combined)
- 2020: Flash loans + oracle manipulation → $1M (small but conceptually huge)
- 2021: Cross-chain bridge with privileged forwarding → $611M
- 2022: Three bridges fall to validator compromise, init bug, and account confusion → $1.1B+ combined
- 2023: Lending protocol with one missing function call → $197M
The total identified value lost or at risk across the cases in this section alone is over $2 billion in 2016-2023 dollars. The fraction recovered (via hard fork, white-hat return, or private reimbursement) is substantial — Wormhole, Poly Network, and Euler were largely or entirely made whole, and Parity's frozen funds remained recoverable in theory. But the unrecovered fraction is also substantial, and most subsequent exploits beyond this section's eight have produced less favorable outcomes.
The patterns are not random. Each case in this section illustrates a class of bug that has recurred in subsequent protocols. The defenses for each class have been documented (Section 3.7), the vulnerability classes have been catalogued (Section 3.8), and the audit practices have matured (Section 3.9). The question is not whether the security industry knows how to prevent these bugs. The question is whether each new protocol, each new codebase, applies the knowledge.
The history suggests: not consistently. The bugs keep happening. The next 3.10.9 will be written about some protocol that is currently in production. The discipline of writing secure smart contracts is not the work of inventing new defenses — it is the work of applying existing defenses, completely and consistently, across every new contract that touches value.
That is the work the rest of this book is about.
Cross-References
- Access control failures — Section 3.8.4 covers the missing-invariant-enforcement pattern as a vulnerability class
- Oracle & price manipulation — Section 3.8.5 covers flash-loan-amplified attacks (Euler's flash loan use)
- Patterns and anti-patterns — Section 3.7 covers the defenses that, applied consistently, would have prevented this
- Audit practices — Section 3.9 covers what audits should catch and the discipline gap that allowed six audits to miss this
- Earlier flash-loan exploits — Section 3.10.3 (bZx) introduced the flash-loan amplification dynamic that Euler exemplified at scale
- Section overview — Section 3.10.0 frames the case studies and their common patterns
- Advanced contract security — Section 3.11 covers MEV, advanced flash loan patterns, and incentive-curve attacks in depth
- Emerging trends — Section 3.12 covers formal verification and other defenses that could catch missing-check patterns systematically