Euler Finance donateToReserves Self-Liquidation / Bad-Debt Exploit (2023-03-13)

  • Name: Euler Finance donateToReserves self-liquidation / bad-debt exploit
  • Date: 2023-03-13
  • Protocol: Euler Finance
  • Chain: Ethereum
  • Category: lending-protocol solvency check bypass / accounting desynchronization / self-liquidation / bad-debt extraction
  • Loss: about $197 million across DAI, USDC, WBTC, stETH, and WETH markets
  • Summary: The attacker used Euler’s self-collateralized mint() loop to build a huge same-asset leveraged position, then called donateToReserves() to burn a large chunk of their eToken collateral into reserves without reducing the matching dToken debt. Unlike deposit(), withdraw(), mint(), burn(), and transfer paths, donateToReserves() never ran checkLiquidity(account). That let the attacker intentionally push their own account underwater, liquidate it from a sister contract at Euler’s maximum soft-liquidation discount, pull out more discounted collateral than the remaining debt really justified, and then withdraw underlying assets from the pools before repaying the flash loan.
  • Trigger / entry point:
    • Euler’s EToken::donateToReserves() path directly wrote assetStorage.users[account].balance = encodeAmount(newBalance) and increased reserveBalance, but never re-checked the donating account’s solvency afterward.
    • By contrast, nearby deposit(), withdraw(), mint(), burn(), and eToken transfer paths all eventually called checkLiquidity(account) on the affected account, so donateToReserves() was a real invariant gap rather than just another balance mutation.
    • Euler’s design also allowed aggressive same-asset leverage through mint(), which simultaneously expanded collateral-side eTokens and liability-side dTokens.
    • The liquidation engine assumed a borrower’s collateral would only be insufficient in narrower cases such as partial seizure across multiple collaterals, not because the borrower had deliberately deleted collateral through an unchecked accounting path.
  • Root cause:
    • Euler let one state transition reduce collateral without reducing debt and without enforcing a post-action health check.
    • That broke the protocol’s implicit invariant that users could not manufacture unbacked debt inside a healthy account path.
    • Once the attacker created this artificial insolvency, Euler’s liquidation discount logic and debt-transfer math converted the bad accounting state into real, withdrawable assets.
    • In short: donateToReserves() was treated like a benign reserve-management action when in practice it was a solvency-changing balance mutation.
  • Exploit path:
    1. The attacker funded the exploit with a 30,000,000 DAI flash loan from Aave V2 and routed the attack through primary contract 0xebc29199c817dc47ba12e3f86102564d640cbf99 against the Euler core 0x27182842E098f60e3D576794A5bFFb0777E025d3.
    2. The attacker deployed a violator contract 0x583c21631c48d442b5c0e605d624f54a0b366c72 and a liquidator contract 0xa0b3ee897f233f385e5d61086c32685257d4f12b, which mattered because Euler only forbade liquidations where violator and liquidator were the same subaccount family.
    3. In the DAI leg, the violator deposited 20,000,000 DAI into eDAI and then called mint(0, 200_000_000e18).
    4. The violator repaid 10,000,000 DAI of dDAI with repay(0, 10_000_000e18), then called mint(0, 200_000_000e18) again. Those two mint() calls let the attacker build a massively levered same-asset position because each call added a large dDAI liability while also crediting matching eDAI balance at Euler’s current exchange rate.
    5. After building that leverage stack, the violator called donateToReserves(0, 100_000_000e18). Because the amount was denominated in eToken book-keeping units, this directly erased 100,000,000 eDAI balance from the violator and moved it into reserves while leaving dDAI debt untouched, pushing the account deeply below water.
    6. The attacker-controlled liquidator contract then called checkLiquidation(liquidator, violator, DAI, DAI) and liquidate(violator, DAI, DAI, repay, yield). In same-asset liquidations Euler’s computeLiqOpp() set repay = type(uint).max, so once the donated-away collateral forced health far enough down, the liquidator could seize nearly the full profitable opportunity and the discount hit Euler’s MAXIMUM_DISCOUNT = 20% cap.
    7. The liquidator withdrew underlying DAI from the pool using the seized eDAI, repaid the flash loan, and the attacker repeated the same reserve-donation plus sister-liquidation pattern across the USDC, WBTC, stETH, and WETH markets.
  • What the attacker needed to be true:
    • donateToReserves() needed to skip the same kind of liquidity / health enforcement that ordinary collateral-changing paths would trigger.
    • Euler’s mint() loop had to allow the attacker to cheaply create a very large same-asset self-collateralized position first.
    • Euler’s liquidation system had to reject only true self-liquidation by subaccount linkage, not by broader beneficial control, so sibling helper contracts could still play violator and liquidator.
    • Liquidation math had to preserve a large discount for the liquidator even when the violator’s insolvency came from this accounting hole rather than ordinary market movement.
    • The targeted pools needed enough withdrawable underlying liquidity for the seized eTokens to turn into real assets.
  • Technical anatomy worth remembering:
    • This was not an oracle attack. The attacker did not need to print a fake external price; they printed insolvency inside Euler’s own bookkeeping.
    • The key bug was not merely missing check in the abstract. It was a specific balance-sheet mismatch: eToken collateral could be destroyed while dToken debt remained intact.
    • The donateToReserves() branch was especially dangerous because it sat right beside other eToken balance-changing paths that did end in checkLiquidity(account), making the omission easy to underestimate during review.
    • Euler’s soft-liquidation mechanism then amplified the bug. computeLiqOpp() built discount from UNDERLYING_RESERVES_FEE + (1 - healthScore), capped it at 20%, and same-asset liquidation let the attacker harvest that discount at scale once they manufactured sufficiently bad health.
    • Euler also only rejected self-liquidation through require(!isSubAccountOf(violator, liquidator)), so separate attacker-controlled helper contracts were enough to capture the liquidation incentive the protocol meant for independent keepers.
    • The exploit also shows how reserve-management functions can be economically dangerous even when they look like secondary admin or accounting helpers.
  • Representative transactions and artifacts:
    • Primary attack contract: 0xebc29199c817dc47ba12e3f86102564d640cbf99
    • Violator contract: 0x583c21631c48d442b5c0e605d624f54a0b366c72
    • Liquidator contract: 0xa0b3ee897f233f385e5d61086c32685257d4f12b
    • Euler core / approval target: 0x27182842E098f60e3D576794A5bFFb0777E025d3
    • Euler liquidation-facing module proxy used in exploit reproductions: 0xf43ce1d09050BAfd6980dD43Cde2aB9F18C85b34
    • Example DAI exploit transaction: 0xc310a0affe2169d1f6feec1c63dbc7f7c62a887fa48795d327d4d2da2d6b111d
    • Example victim market (eDAI): 0xe025e3ca2be02316033184551D4d3Aa22024D9DC
    • Example DAI debt token (dDAI): 0x6085Bc95F506c326DCBCD7A6dd6c79FBc18d4686
    • Public exploit reproductions model the DAI leg as ending with the liquidator holding about 310.93M eDAI against about 259.31M dDAI, then withdrawing about 38.9M DAI before flash-loan repayment.
    • Publicly reported attacker EOAs controlling funds post-exploit included 0xB2698... and 0xb66cd....
  • Response / mitigation:
    • Public postmortems traced the vulnerable path back to the donateToReserves() feature introduced in Euler’s eIP-14 upgrade set.
    • The incident became a canonical case for why every collateral-changing path in a lending system needs the same solvency invariants, not just the obviously user-facing ones.
    • It also became a clean review lesson on liquidation-boundary assumptions: forbidding only direct subaccount self-liquidation does not help if attacker-controlled sibling contracts can still internalize the keeper discount.
    • Euler ultimately recovered the stolen assets after an unusual post-exploit negotiation and recovery effort, but the exploit remains a core lending-design case study.

Pattern tags

  • solvency-check-bypass
  • self-liquidation-monetization
  • Helper-path invariant drift / reserve-management helper retained authority to delete collateral without reusing the same post-mutation solvency boundary as ordinary eToken balance changes
  • Borrower–liquidator separation failure / attacker-controlled sister account captured Euler’s own soft-liquidation discount after manufacturing the insolvency state locally

Three solvency-and-liquidation promotions this incident collapsed

  1. donateToReserves() could touch live collateral state inside a debt-bearing account

    • Euler exposed a user-callable helper that could erase eToken balance and move it into reserves even while the same account still carried dToken debt.
    • That alone only meant a reserve helper could mutate economically relevant state. It did not yet have to mean the resulting post-donation balance sheet counted as canonical solvency truth.
  2. Euler still promoted that helper-authored post-donation state into canonical solvency meaning

    • After the donation, the protocol’s debt ledger, collateral ledger, reserve ledger, and liquidation precomputation all treated the new less collateral / same debt state as if it had been produced by a path that had survived the normal checkLiquidity(account) boundary.
    • This was the decisive promotion: a weaker reserve-management path was allowed to publish the same balance-sheet meaning as ordinary validated collateral mutations.
  3. That accepted helper-authored state still retained full liquidation and withdrawal authority

    • A sister liquidator contract could call checkLiquidation() / liquidate(), capture Euler’s capped same-asset discount, and then turn seized eTokens back into underlying assets quickly enough to repay flash-loan capital inside the attack bundle.
    • The helper touched collateral, the protocol accepted that result as one coherent solvency state, and downstream liquidation + withdrawal paths could still spend against it were three different approvals, and Euler collapsed them together.

Why the exploit worked

  • Euler let donateToReserves() mutate the same economically meaningful object as withdraw(), burn(), and eToken transfers — user collateral balance — without reusing the same post-state liquidity proof. The protocol therefore created a side path where collateral could disappear while debt stayed fully live.
  • The exploit only became protocol-scale because Euler also exposed a fast same-asset leverage scaffold through mint(). That meant the attacker could cheaply build a huge self-collateralized eDAI / dDAI stack first, then delete a large slice of the collateral side with one unchecked reserve donation.
  • Liquidation math then turned the accounting hole into an attacker payout rail instead of a recovery rail. Once the donated-away collateral forced health low enough, same-asset liquidation set repay = type(uint).max, the discount curve hit the 20% cap, and the attacker-controlled liquidator contract seized more discounted collateral than an honest market-loss scenario would have justified.
  • Euler’s anti-self-liquidation boundary was too thin to matter. The code only rejected same-subaccount-family liquidations, but the exploit economics survived perfectly well once the attacker split roles across a violator contract and a liquidator contract.
  • The preserved lesson is therefore broader than missing check on donateToReserves(): reserve helpers, same-asset leverage loops, liquidation discounts, and borrower–liquidator independence all belonged to one solvency object, and Euler split that object across paths that no longer agreed.

Detection and audit pivots

  • Enumerate every path that can reduce collateral or increase effective reserves without reducing matching debt. If a helper, donation, migration, rescue, or sync path can change post-action health, it should be reviewed like withdraw() rather than like bookkeeping.
  • Compare all eToken balance-changing paths side by side. In Euler’s code the critical question was not does donateToReserves() look dangerous in isolation? but why does this one collateral-mutating path skip the same checkLiquidity(account) boundary that nearby paths reuse?
  • Model borrower and liquidator as one adversary during review. If the same actor can create insolvency and also capture the liquidation discount from a sister account, liquidation incentives become part of the exploit surface rather than part of the defense.
  • Stress-test same-asset leverage plus collateral-deletion flows together. A helper that looks small on an unlevered account can become catastrophic once mint() or recursive borrowing has already magnified the position size that one unchecked mutation can push underwater.
  • Ask what reserve-management functions are really allowed to mean. If donateToReserves() is framed as harmless reserve support but can still rewrite user collateral state, the function is already a solvency surface and should inherit the same invariants, monitoring, and circuit-breakers as ordinary borrow-facing paths.

Failure-prevention checklist

  • Reuse one canonical post-mutation liquidity boundary for every path that can reduce collateral, including reserve donations, rescue helpers, migrations, collateral toggles, and admin-sounding accounting utilities. If a path can delete eToken balance, it should inherit the same solvency proof as withdraw() or fail closed.
  • Treat reserve-management surfaces as solvency code, not bookkeeping code. A user-callable reserve donation should either be forbidden while debt is live, limited to explicitly surplus value, or forced to prove that the donating account stays healthy after the transfer.
  • Model borrower and liquidator as the same adversary during design and testing. If a sister account can capture the protocol’s liquidation discount immediately after manufacturing insolvency, the discount has become part of the exploit surface.
  • Stress-test same-asset leverage loops together with collateral-deletion paths. mint()-style leverage scaffolds magnify the blast radius of any later helper that can burn collateral or rewrite reserves without touching debt.
  • Bound same-asset liquidation monetization with tighter close-factor, discount, or delayed-withdraw controls when the collateral mark and the liability sit in the same market. A protocol should not let one locally manufactured shortfall immediately cash out the full keeper spread at scale.
  • Add invariant tests that compose mint() -> repay() -> mint() -> collateral mutation -> liquidation -> withdraw() across sibling accounts. Euler’s real failure only becomes obvious once helper-path accounting drift and liquidation capture are tested in one bundle.
  • Monitor for user-authored collateral destruction followed by near-immediate liquidation from a correlated address cluster. That pattern is not ordinary keeper behavior; it is usually a sign the protocol has started paying attackers to consume their own fake bad debt.

Audit prompts

  • Which exact functions can reduce eToken balance, move value into reserves, or otherwise change collateral state without also reducing the corresponding dToken debt? Do all of them end in the same checkLiquidity(account) proof?
  • If donateToReserves() or a similar helper is callable by users, what economic claim is it really making: support reserves, or destroy live collateral? Where is that difference enforced in code?
  • Can one beneficial owner still internalize liquidation incentives through sibling contracts even if direct self-liquidation by subaccount is blocked? What concrete assumption makes liquidator independence real here rather than cosmetic?
  • How does liquidation math behave when insolvency is attacker-authored rather than market-authored? In Euler’s same-asset case, what exactly prevents repay = type(uint).max plus a capped discount from becoming a guaranteed payout rail?
  • Which leverage-building paths can enlarge a position before the unchecked helper runs? If an attacker can first magnify both eToken collateral and dToken debt, how much value can one later collateral deletion move across the solvency boundary?
  • After liquidation, how quickly can seized collateral become withdrawable underlying that repays flash-loan capital? If the answer is inside the same attack sequence, what other control still meaningfully contains loss?
  • Are reserve, donation, or accounting utilities reviewed with the same suspicion as deposits, borrows, and liquidations, or are they still implicitly classified as low-risk maintenance code?
  • If a helper path were forced to prove debt unchanged + collateral reduced => account still healthy after every mutation, would the exploit disappear entirely? If yes, the helper already sits on the protocol’s real solvency boundary.

Lessons that should already be standard

  • Reserve-management code is lending-risk code. The moment a reserve helper can change user collateral without changing debt, it is no longer secondary bookkeeping.
  • No direct self-liquidation is not a serious defense if sister contracts can trivially recreate the same economics. Liquidator independence must be an explicit adversarial assumption, not a naming convention.
  • Same-asset leverage turns small invariant gaps into protocol-scale losses. When the protocol lets users recursively grow both sides of a position, any later unchecked collateral mutation becomes much more dangerous.
  • Liquidation discounts do not rescue a protocol from attacker-authored insolvency; they often finance the exploit. Once the attacker can create the shortfall and also capture the keeper spread, the protocol is paying for its own drain.
  • Internal accounting failures can be as terminal as oracle failures. Euler did not need a fake market price because its own bookkeeping let absent collateral masquerade as a liquidatable but still monetizable position.
  • Review should stop treating donate, reserve, and other maintenance verbs as harmless by default. Economic meaning lives in state transitions, not in function names.

Why this incident matters

  • Euler is the cleanest major example of a lending protocol being broken by internal accounting, not by price manipulation.
  • It shows how self-liquidation can become a profit center when the protocol lets users manufacture bad debt before liquidation math runs.
  • It is also a reminder that reserve or donation code paths can be part of the same attack surface as deposits, borrows, and liquidations.

Internal linkages

  • Core recurring pattern: solvency-check-bypass.
  • Sharper helper-path design companion: helper-path-invariant-reuse-and-fail-closed-discipline — donateToReserves() should have reused the same post-mutation liquidity boundary as other collateral-changing paths or failed closed once reserve donation could change solvency.
  • Monetization lens: self-liquidation-monetization — Euler is the clean canonical case where attacker-manufactured insolvency only became a protocol-scale drain because a sister account also captured the liquidation discount.
  • Positive-design companion: liquidator-independence-assumptions — the protocol only rejected direct subaccount self-liquidation, not broader borrower–liquidator beneficial-control equivalence.

Control surface