The Auditor’s Take is a weekly series by Jon Stephens, CEO of Veridise. This article is about a solvency invariant that was correctly enforced until a later upgrade, an upgrade that was itself audited. New take every week. Find Jon at @FormallyJon.
A protocol can run the same safety check in every function that moves user funds, then add one new function that leaves it out. Every other function performs the check; the new one does not, and nothing flags that it is missing. That is the shape of the bug that drained about $197 million from Euler Finance in March 2023, the second-largest DeFi hack of the year.
TLDR:
donateToReservescut the caller’s collateral with no solvency check, unlike every other fund-moving function- Insolvency plus a scaling 20% liquidation discount made the attack profitable
- Cream and Sonne lost $130M and $20M to the same insolvency class
- Finding the missing check is easy; proving it exploitable needs the whole protocol
- Audit every contract upgrade against the whole protocol, not in isolation
An Invariant Is Only as Strong as the Function That Skips It
Lending protocols share a key invariant: after any operation that moves a user’s assets, that user must still be solvent, with collateral covering debt. The property is sound; enforcement is what fails. Every function that can deposit, withdraw, borrow, or transfer has to confirm the account is healthy before returning. In many cases this confirmation is not enforced by the design, but requires a solvency check be added at the end of the relevant functions. While these checks are critical to the safety of the protocol, if such a check is missed the protocol will still compile and tests may still pass.
An insolvency bug lets an account owe more than its collateral is worth, which is difficult to fix. The borrower has no reason to repay, since the debt exceeds the returned collateral value. Liquidation cannot resolve the issue either because auctioning the collateral raises less than the debt. What is left is bad debt on the protocol’s books.
The class has produced some of the largest losses in lending. Cream Finance lost about $130 million in October 2021 when manipulated share pricing doubled what the protocol thought an attacker’s collateral was worth and let them borrow the pools dry, per Immunefi’s analysis. Sonne Finance lost roughly $20 million in May 2024, when a donation into an empty market inflated its exchange rate until a few wei of collateral backed multimillion-dollar borrows. Euler is the quietest of the three: no price was manipulated and no market sat empty. Every valuation was correct, and one function that reduced collateral without re-checking solvency did the damage.
Euler: A Health Check That Never Ran
Euler Finance was a non-custodial lending protocol on Ethereum: users deposited assets, received eTokens as collateral, and borrowed against them. In March 2023 an attacker drained roughly $197 million across six tokens, including DAI, USDC, WBTC, and stETH, in a series of flash-loan transactions.
The entry point was donateToReserves, which was added to the eToken contract in an upgrade months earlier. It let a user donate their own eToken balance to the reserves, reducing the caller’s collateral but not their debt. Unlike every other function that changed a balance, it never checked whether the caller stayed solvent.
The Bug, and Why It Was Easy to Miss
Here is the deployed function.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
function donateToReserves(uint subAccountId, uint amount) external nonReentrant { (address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER(); address account = getSubAccount(msgSender, subAccountId); updateAverageLiquidity(account); emit RequestDonate(account, amount); AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); uint origBalance = assetStorage.users[account].balance; uint newBalance; if (amount == type(uint).max) { amount = origBalance; newBalance = 0; } else { require(origBalance >= amount, "e/insufficient-balance"); unchecked { newBalance = origBalance - amount; } } assetStorage.users[account].balance = encodeAmount(newBalance); assetStorage.reserveBalance = assetCache.reserveBalance = encodeSmallAmount(assetCache.reserveBalance + amount); emit Withdraw(assetCache.underlying, account, amount); emitViaProxy_Transfer(proxyAddr, account, address(0), amount); logAssetStatus(assetCache); } |
It writes the caller’s balance down and credits the reserve. Euler’s other fund-moving functions, deposit, withdraw, mint, burn, and transfer, all end by calling an internal checkLiquidity that reverts if the account would be left unhealthy. This one never calls it.
Alone, the omission looks harmless. Two other Euler mechanisms are what turned it into an exploit.
The first is self-collateralization: a user could borrow against their own deposit, and Euler let such a position run up to a 95% loan-to-value ratio, so the debt could sit just under the collateral backing it. The second is the liquidation discount, which was not fixed. The discount grew as the borrower’s health score fell, to a maximum of 20%. This means the less healthy a position became, the cheaper its collateral was to take over.
Those two combine with the missing check as follows. The attacker borrowed against their own deposit to build a position near that 95% limit, then called donateToReserves to give away most of the collateral. The debt stayed where it was, and with no health check on this path, nothing stopped the position from being left far underwater. Because the discount grew as health fell, the position now offered close to the full 20%, and a second account the attacker controlled liquidated it to take the remaining collateral at that price. The debt that collateral no longer covered stayed behind as bad debt.
The missing check alone did not make the attack financially viable. Rather, it cost $197 million because of the interactions between these two mechanisms once a position became insolvent. This is also why it was hard to spot: read on its own, donateToReserves is could be reasonable.
How a Senior Auditor Catches It
A review of the invariants that every state-changing entry point should enforce belongs at the opening of any smart contract audit, in the threat modeling phase before the line-by-line read begins. In a DeFi audit you establish what must hold whenever assets move. In this case, that no external call may leave the caller insolvent. Once established, list every externally callable function that can change a balance and confirm each re-establishes solvency before returning. Run that list for Euler and the donation path is the one without a health check.
The moment that matters is refusing to skip the donation function because donating sounds harmless. Despite the intention, the fact that this may reduce the caller’s collateral balance flags that the function requires a closer inspection. Collateral is one side of the solvency test, so any function that can move it has to be checked. Ask “can this function change a balance?” rather than “does this look risky?” and the omission stands out.
Finding the omission is the first half of the job. The harder half is deciding whether anyone can profit from it. That question cannot be answered from donateToReserves alone. Answering it means reading the surrounding code: what a user may borrow against their own deposit, and what a liquidator collects once a position goes underwater. Which is the case against reviewing an updated function in isolation. The omission is in the function; the profit is somewhere else.
Why Manual Review Misses It, and How Tooling Helps
The reasoning is straightforward; doing it exhaustively across a large protocol is not. When a protocol ships an update, the new function is what the engagement covers, while the interactions that make it dangerous sit in code frequently outside the stated scope. A review of the diff can be carried out properly and never reach them.
Upgradeability adds a second problem, the one that mattered here: code that can be replaced after deployment means an invariant confirmed during an audit can be violated later by an upgrade nobody reviewed against the whole system. Some proxy patterns, the diamond pattern in particular, add a further step, since you have to establish which implementation contract a call reaches before reasoning about what it does.
Tooling runs the same enumeration automatically, so nothing depends on a reviewer holding the whole protocol in their head. A Vanguard custom detector encodes the rule directly: every function that can modify a user’s balance must reach a checkLiquidity call before returning. It flags any function that skips the call, and any path that avoids it. Pointed at Euler’s EToken contract, it flags donateToReserves and nothing else, because every other balance-changing entry point does reach the check. The runtime counterpart is a fuzzer such as OrCa: encode the rule that no external call may leave the caller insolvent, and let it search for a sequence that breaks it.
This is also work a development team can own rather than wait on an audit. Once an invariant matters enough to write down, it can be encoded as a detector and a specification and run on every commit, so the next function that touches a balance is checked the day it is written.
What Carries Beyond Euler
The lesson here is about upgrades. donateToReserves was added in an upgrade that was itself reviewed, and the check still shipped missing. A diff can be locally correct and still break a global invariant, because the property it violates lives in code the diff does not touch. That is why, when we audit an upgrade at Veridise, we build in enough time to explore how the new code interacts with the wider protocol. That time is what makes an interaction like this one findable, rather than only confirming the diff is correct on its own terms.
Working on a lending protocol?
If your protocol enforces a solvency invariant across many entry points and you are shipping an upgrade, a missing check is usually visible in the new code. Whether it is exploitable usually is not, because that answer lives in how the new code interacts with everything already deployed. If you want both reviewed before it ships, talk to us.
The Takeaway
A solvency invariant has to hold on every path that can change a balance, and confirming it on the functions under review says nothing about the one that was just added. Euler enforced its health check everywhere except one function added in a later upgrade. This, combined with the protocol’s self-collateralization limit and its scaling liquidation discount, was enough to cost $197 million. The next time you review an upgrade, make sure the reviewer is not only considering the diff that contains the new logic. It matters just as much to confirm that the interactions between the new code and the existing logic are safe.