Lending Optimizers - Reimagined with Euler
A layer-by-layer derivation of a spread-compression Optimiser on Euler V2. The Morpho Optimiser answered each layer with custom code because Aave's monolithic pool gave it nowhere else to put matched dollars; Euler V2's primitives let you answer the same layers by composing vaults instead. Two reasonable points in the design space, made possible by different substrates.
The spread, and the matching engine
This piece is the sequel to You Could Have Invented Morpho Optimizer, but you don’t need to have read it. Here is the refresher in a paragraph.
A lending pool (Aave, Compound, Morpho Blue) pools suppliers’ cash and lets borrowers draw from it. Suppliers earn interest, borrowers pay interest, and the pool keeps a buffer of idle liquidity so suppliers can withdraw on demand. That idle buffer creates a structural spread between what borrowers pay and what lenders earn.
Morpho Blue USDC market:
Borrow APY: 8.0% ← what borrowers pay
Supply APY: 6.4% ← what suppliers earn
The gap is structural. With $100 supplied and $80 borrowed at 8%, borrowers pay 80 × 8% = $6.40 a year, and that $6.40 is averaged over all $100 of supply: the $20 kept idle for withdrawals earns nothing but dilutes everyone.
An Optimiser sits on top of a pool and matches lenders with borrowers in the middle of the spread. Matched, both sides beat the pool. Unmatched, capital falls back to the pool: same outcome as not having an Optimiser at all. The Morpho Optimiser put Morpho on the map doing exactly this on top of Aave and Compound.
How? Because Aave was a monolithic pool, it had no place to put matched dollars, so the Morpho Optimiser tagged them in place. Every supplier and borrower carried two balances: onPool (still in Aave, earning the pool’s rate) and inP2P (matched, earning a rate inside the spread). “Matching” meant walking lists of users and flipping their tags from onPool to inP2P; “unmatching” did it in reverse when someone left.
That per-user model has consequences. To make it work, the Morpho Optimiser built a matching engine: two scaled balances per user, four interest indexes, sorted doubly-linked lists of every supplier and borrower, gas budgets to bound the matching loops, and “delta” accounting to record loops it couldn’t finish. Thousands of lines of carefully audited code: the price of giving every match its own per-user accounting, on a substrate that offered no other place to put it.
The question this tutorial explores: on Euler V2, what does the same Optimiser look like when matched-ness can live in a vault instead of in per-user state? The prototype’s four contracts total about 460 lines, of which roughly 30 are genuinely novel, though “novel lines” is a misleading metric in isolation, since the design leans heavily on Euler’s wider stack (EVK, EVC, EulerEarn) for behaviour the Morpho Optimiser implemented itself. The rest of this page is the derivation of how each layer translates.
One nice irony to note up front: the pool we will optimise is itself a Morpho Blue market (USDC against cbBTC collateral). The Optimiser pattern can sit on top of any lending protocol: earlier Morpho Optimisers ran on Aave and Compound; this one sits on Morpho itself. The host changed; the idea didn’t.
The matchmaker, and a different bet
The matchmaker kernel is the same in any Optimiser: on supply, find a borrower and pair with them; on borrow, find a supplier and pair with them; otherwise use the pool. The Morpho Optimiser made one defining decision about how to represent a pairing:
In the Morpho Optimiser, “matched-ness” is a property of each user. Every user carries onPool and inP2P balances. To match, unmatch, promote, or demote, you walk lists of users and edit their tags.
Every hard layer of that design descends from this one decision. Linked lists exist to find the users. Gas budgets exist to bound the iteration over users. Deltas exist to record the users you ran out of gas before reaching. The data structures are all in service of a per-user model.
So make a different bet:
The bet. What if matched-ness is a property of where the money sits, not of who owns it? Put all matched capital in one place and all unmatched capital in another. “Matching” becomes moving tokens between two places. There is no list of users to walk; the system tracks two balances, not N users.
This bet only pays off if “a place” is cheap to create, cheap to give an interest rate, and cheap to move money in and out of. On Aave’s monolithic pool in 2020, it wasn’t, which is why the Morpho Optimiser took the per-user route instead. On Euler V2 in 2026, the primitives are there. The eight layers that follow are the cashing-out of that bet, and the trade-offs that come with it.
A vault is a bucket
The Morpho Optimiser splits each user into onPool / inP2P because matched and unmatched USDC are commingled inside one pool contract: a per-user tag is the only way to tell two dollars apart. We need matched and unmatched dollars to live somewhere genuinely separate.
Euler V2’s foundational primitive: every lending market is its own ERC-4626 vault: an EVault. Not a row in a shared pool. A sovereign contract: its own asset, its own interest-rate model, its own configuration, deployable by anyone.
So don’t tag users. Make two vaults.
- Unmatched USDC → supplied into the Morpho Blue market. Earns Morpho’s supply rate. This is the fallback: exactly the role Aave and Compound played for the Morpho Optimiser.
- Matched USDC → a brand-new EVault, the matched vault. Borrowers borrow from this vault, and only this vault.
onPool / inP2P struct is gone. The bucket is the vault.A user’s “two buckets” are now simply the share tokens of two vaults. No struct, no per-user matching state.
The moment this clicks, three Morpho Optimiser layers have nothing to do in this design. There is no per-user matching state, so there is no linked list to find users in, and (Layer 3 will show) no loop to iterate. Those subsystems aren’t worse engineering; they answered a question this design isn’t asking, and gave up something this design gives up too (per-user fairness, which Layer 3’s table shows we replace with pro-rata sharing).
The matched vault’s rate
The matched vault needs an interest rate. The Morpho Optimiser grew a p2pSupplyIndex and a p2pBorrowIndex, advancing them every block by a blend of the pool indexes. Where does our matched vault’s rate come from, and do we need that whole index apparatus?
Two things we get for free; one thing we build.
Free: the index
An EVault is already an ERC-4626. Its share price is the index: you deposit assets, you receive shares, and the share price rises as interest accrues. We don’t write index math; the vault has it. And because there is exactly one matched vault with one share price, we need exactly one index, not a supply/borrow pair.
Free: one rate for both sides
This is the keystone of the entire design. We will run the matched vault at ~100% utilisation at all times (Layer 3 enforces it) and at 0% protocol fee. At full utilisation with no fee, every dollar of borrower interest lands on exactly one dollar of supply. So:
matched rate = what borrowers pay = what lenders earn
The Morpho Optimiser needed two P2P indexes because its matched bucket could be partly idle, so the two sides drifted apart. Ours can’t be idle, so a single rate serves both. One vault, one index, one rate.
Build: the rate itself
An EVault’s interest-rate model is a pluggable contract. Euler calls an address and uses whatever rate comes back. The contract can read anything on-chain. So we write one, IRMMorphoSpread, that reads the underlying Morpho market and returns a rate sitting inside Morpho’s own spread:
function computeInterestRate() returns (rate):
market = MORPHO.market(MARKET_ID)
borrowRate = morphoIRM.borrowRate(market) // Morpho's own borrow rate
utilization = market.totalBorrow / market.totalSupply
supplyRate = borrowRate × utilization × (1 − fee) // reconstruct Morpho's supply rate
spread = borrowRate − supplyRate
return supplyRate + spread × lenderShareBps / 10000 // ← the matched rate
The lenderShareBps parameter is the Morpho Optimiser’s p2pIndexCursor, reincarnated as a single immutable. It decides where in the spread the matched rate sits:
Quasi-fixed, by construction, for better and worse. Notice what the formula does not read: the matched vault’s own utilisation. The matched rate tracks Morpho’s broad market and is blind to local supply and demand. It moves only when Morpho moves, slowly. Upside: predictability, and the hinge that makes Layer 8 work. Downside: the matched vault cannot rate-clear its own imbalances the way the Morpho Optimiser’s bucket could, and the IRM makes an external call into Morpho on every accrual: a cross-protocol dependency in the interest-accrual hot path. The Morpho Optimiser’s indexes were internal math.
Keeping the matched vault full
Layer 2’s keystone, “one rate for both sides”, is only true while the matched vault is ~100% utilised. The instant it holds idle cash, matched lenders earn the IRM rate on part of their money and 0% on the rest, and the promise breaks. We need a rule that keeps the vault full.
The rule: never put cash into the matched vault unless it is borrowed in the same breath, and pull cash out the instant a borrow is repaid.
This is our “matching.” Now place it side by side with the Morpho Optimiser’s matching, for a $1000 borrower:
| Morpho Optimiser | Euler Optimiser | |
|---|---|---|
| To match $1000 | Walk suppliersOnPool, slice supplier after supplier, flip each one’s onPool/inP2P | Move $1000 from the Morpho vault into the matched vault, then borrow it |
| Cost | O(number of suppliers touched) | O(1): one reallocation |
| Backing 10,000 borrowers? | 10,000× the work | Identical: it’s one pooled position |
| Fairness across lenders | FIFO: specifically matched suppliers earn the matched rate | Pooled pro-rata; arrival order doesn’t matter, no per-supplier claim |
| Liveness on lender exit | Promote/demote internally: swap a stuck matched lender for another supplier | None internally: needs Layer 8’s escape valve (and its own liquidity) |
function borrow(amount):
if matchedVault.cash() >= amount:
matchedVault.borrow(amount) // already funded, just borrow
else:
reallocate(morphoVault → matchedVault, amount) // pull liquidity just-in-time
matchedVault.borrow(amount) // ...and it's 100% utilised again
function repay(amount):
matchedVault.repay(amount)
reallocate(matchedVault → morphoVault) // sweep the freed cash back to yield
That one structural choice, a pooled matched vault instead of per-pair matching, makes four Morpho Optimiser layers unnecessary in this design (at the cost of pooled rather than first-come fairness):
| Morpho Optimiser machinery | Why this design doesn’t need it |
|---|---|
| Sorted doubly-linked lists | No users to iterate: the matched bucket is one pooled position |
maxGasForMatching | No loop to bound: reallocation is O(1) |
p2pSupplyDelta / p2pBorrowDelta | No half-finished iteration to remember |
MatchingEngine: promote / demote | Moving pooled liquidity is a single transfer, though the engine’s demote path also gave matched lenders an internal exit, a property reinstated only externally in Layer 8 |
The Morpho Optimiser’s linked lists, gas budgets, and deltas all exist to solve one problem: iterating over many individual matched users. Make the matched bucket a single shared vault and that problem doesn’t arise, though it’s worth being explicit that the pooled model is also why per-user fairness is gone. The contract that performs the reallocations, the BorrowRouter, is stateless and never custodies a cent; it only sequences calls.
One front door for lenders
Two loose ends. (1) A lender shouldn’t have to choose “Morpho vault or matched vault?” or babysit the position as borrowers arrive and leave; they want one deposit, automatically optimised. (2) In Layer 3 the router “moves $1000 from the Morpho vault to the matched vault.” Whose $1000? Matched capital is shared, so no individual lender owns it. Someone must hold a pooled Morpho position and a pooled matched position and rebalance between them.
Both ends are tied off by the same primitive: EulerEarn, a yield-aggregator vault. Lenders deposit into EulerEarn and hold one share token. EulerEarn itself holds positions in several strategy vaults, and an allocator decides the split.
Wire it with two strategies: the Morpho vault and the matched vault:
- “A lender deposits” = deposit into EulerEarn. It parks in the Morpho strategy by default, earning the 6.4% fallback.
- “Move liquidity Morpho → matched” = EulerEarn rebalances between its two strategies, and the router triggers that rebalance as the allocator.
One wrinkle: EulerEarn strategies must be ERC-4626, and Morpho Blue is not 4626-native. So we add a thin wrapper, MorphoSupplyVault, a minimal, immutable 4626 vault around the Morpho supply position. (It is deliberately donation-immune: its totalAssets counts only the Morpho position, so nobody can skew the share price by sending it loose tokens.)
Locking the matched vault’s door
Layer 2’s keystone, threatened again. The matched rate equals “what borrowers pay” only while the vault is 100% utilised. But an EVault is, by default, permissionlessly depositable. If a stranger drops idle USDC into the matched vault, utilisation falls below 100%, every matched lender’s real yield drops below the IRM rate, and the stranger skims matched yield they did nothing to earn.
The Morpho Optimiser never faced this: matched-ness was a tag it alone could write. Our matched bucket is a real, public vault. We must lock its door.
Euler’s primitive: hooks. A vault’s governor can install a hook contract that runs before chosen operations and may revert them. Install one, MatchedVaultHook, that permits deposit and mint only when the caller is the allocator (EulerEarn):
fallback():
// the EVK appends the authenticated caller (20 bytes) onto msg.data
caller = address(last 20 bytes of msg.data)
if caller != ALLOCATOR:
revert OnlyAllocator()
Now the only way USDC enters the matched vault is through Layer 4’s front door, on the schedule of Layer 3’s router. The keystone holds. (Note: borrowing is not hooked; anyone may borrow. Only funding is restricted, because only funding can dilute the rate.)
Atomic moves across vaults
Layer 3’s borrow flow is several steps across several vaults: enable the cbBTC vault as collateral, enable the matched vault as controller, reallocate Morpho → matched, deposit cbBTC, borrow USDC. As separate transactions this is unsafe: someone races in and borrows the freshly reallocated liquidity; or a vault’s solvency check fires mid-sequence, when the collateral deposit hasn’t landed yet, and reverts a perfectly fine end state.
Euler’s primitive: the EVC (Ethereum Vault Connector). Two jobs. It batches calls so the whole sequence is one atomic transaction. And inside a batch it defers every vault health check to the end, so intermediate states that look unhealthy (borrowed, but collateral not yet registered) don’t revert; only the final state is judged.
evc.batch([
enableCollateral(user, cbbtcVault), // the user's calls
enableController(user, matchedVault),
earnVault.reallocate(morpho → matched), // the router's call (as allocator)
cbbtcVault.deposit(cbBTC, user),
matchedVault.borrow(USDC, user),
])
// every vault health check is deferred to here: the end of the batch
This is why the borrow and repay flows are only a handful of lines: the EVC supplies the atomicity and the deferred checks; the router merely lists the steps.
Risk and liquidation, inherited
We have a matched lending vault. We have not built price oracles, collateral factors, liquidation thresholds, or a liquidation engine. Reinventing those is a year of careful, dangerous work.
Same answer as the Morpho Optimiser’s Layer 7: don’t build them: inherit them.
The matched vault is an ordinary EVault, and Euler already ships the whole risk stack: collateral relationships tracked by the EVC, an oracle framework, and a liquidation module. The borrower deposits cbBTC into a cbBTC EVault, enables it as collateral, and enables the matched USDC vault as their controller. If the position goes underwater, Euler’s standard liquidation seizes the cbBTC. The Optimiser writes zero risk code.
- No new risk surface added by the Optimiser. A matched-vault borrower’s risk profile is exactly an Euler borrower’s: same oracles, same thresholds, same liquidation mechanics. No new code paths to liquidation.
- Composability. The matched vault is a normal citizen of the Euler ecosystem, and that is precisely what the final layer needs.
- But the inherited surface is wider. “Writes zero risk code” describes what’s in this repo, not what this design depends on. The Morpho Optimiser inherited one host’s risk stack (Aave or Compound). This design inherits Euler V2’s (EVC, oracle framework, liquidation module, hook system, EulerEarn allocator) and Morpho Blue’s (via the IRM). Whether a larger, more recently-shipped surface is acceptable depends on how you weigh protocol age, audit history, and the cost of a dependency-bug cascade: Euler V1’s 2023 exploit is a reminder that “inherited” is not free.
The escape valve
The parasite problem. An Optimiser feeds on its host: every matched borrower is interest that no longer flows to the host pool. Rational actors keep migrating across, the host drifts toward full utilisation and grows fragile. And this design has a liveness fragility the Morpho Optimiser didn’t: a matched lender whose capital is 100% utilised cannot withdraw: there is no idle cash to give them, and no per-user identity to promote/demote with. (The Morpho Optimiser handled this internally; its matching engine would swap a stuck matched lender out for another supplier on the pool. The pooled design has no equivalent, and has to reinstate liveness from outside.)
The fix (proposed in the original thread, and uniquely cheap on Euler) is a liquid secondary market, built as one more vault.
Because Layer 7 kept the matched vault a normal EVault, and because any EVault can be configured to accept any other EVault’s shares as collateral, deploy eUSDC-variable: a second USDC vault with an ordinary variable-rate IRM, configured to accept matched-vault shares as collateral. Both sides are USDC, so the only risk between them is interest-rate drift: the LTV can be set very high, around 95%.
The escape valve. A matched lender whose capital is fully utilised deposits their matched-vault shares as collateral into eUSDC-variable and borrows USDC out. They have exited an illiquid position immediately, without forcing any borrower to repay. They keep earning the matched rate on the collateral and pay the variable rate on the loan; net cost is the spread. The honest caveat: this exit is only as live as eUSDC-variable itself. If the variable vault is near full utilisation, the escape narrows; under stress the spread paid to exit can blow out. The Morpho Optimiser’s internal promote/demote had no equivalent external-liquidity dependency: it traded engine complexity for self-contained liveness, where this design trades self-contained liveness for engine simplicity.
And it generalises into an interest-rate-swap market. Recall Layer 2: the matched rate is quasi-fixed. So the matched vault is a (quasi-)fixed leg and the variable vault a floating leg. Depositing and borrowing in each are the four legs of a swap; traders take leveraged views on the spread. A liquid secondary market is also the cure for the parasite problem: lenders exit by swapping rather than by draining the host, so the host is no longer cannibalised.
This layer is not in the prototype’s four contracts; it is the natural next one. The point is that it costs one vault and some configuration, not a new protocol, precisely because Layer 7 kept the matched vault a standard EVault.
The full picture
Eight layers in, the shape of the architecture should be clear. Four small contracts, each a specific instance of an Euler primitive, wired around infrastructure that already existed:
- IRMMorphoSpread: the matched vault’s interest-rate model. Reads Morpho’s state, returns a rate inside the spread;
lenderShareBpssets the split. - MatchedVaultHook: the bouncer. Permits deposits into the matched vault only from the allocator.
- MorphoSupplyVault: a minimal, immutable, donation-immune ERC-4626 wrapper so the Morpho market can be an EulerEarn strategy.
- BorrowRouter: stateless periphery. Bundles “reallocate then borrow” and “repay then sweep” into atomic EVC batches.
The translation table
Here is the whole thesis in one table: every piece of Morpho Optimiser machinery, and what plays its role here:
| Morpho Optimiser | Euler Optimiser |
|---|---|
onPool / inP2P: two scaled balances per user | Two separate vaults; your balance is just vault shares |
| Pool + P2P index pairs: four indexes | Each vault’s own ERC-4626 share price; one matched index, not a pair |
p2pIndexCursor | lenderShareBps: one immutable in IRMMorphoSpread |
| Sorted doubly-linked lists | Nothing: the matched bucket is one pooled vault; no users to iterate |
maxGasForMatching | Nothing: O(1) liquidity moves, no loop |
p2pSupplyDelta / p2pBorrowDelta | Nothing: no half-finished iteration to record |
MatchingEngine: promote / demote | BorrowRouter: one reallocate call (no per-user promotion exists) |
| The matchmaker supply/borrow cascade | EulerEarn allocator + the router |
reserveFactor | The matched EVault’s standard interest-fee parameter |
| Inherited collateral / oracle / liquidation | Inherited from Euler (EVC + liquidation module) |
| Per-supplier match identity (FIFO fairness) | None: pooled pro-rata; arrival order is moot |
| Internal liveness: promote a stuck matched lender out | None internally: depends on Layer 8’s escape valve and its liquidity |
| Self-contained matched-ness invariant | Upheld by router convention plus a permissionless sweep |
| Internal P2P index math | IRM makes an external call into Morpho on every accrual |
| Local rate-clearing inside the matched bucket | None: matched rate is quasi-fixed; clears only via Layer 8’s market |
| Single-host composition (Aave or Compound + Optimiser) | Three-protocol composition (Euler EVK + EulerEarn + Morpho Blue) |
| – | MatchedVaultHook: new; a public vault must be told who may fund it |
| – | The EVC batch: new leverage; makes the multi-vault flow atomic |
Flaws and caveats
Inherent to any Optimiser:
- Parasitism. Matched volume is interest the host no longer earns; at scale the host drifts toward full utilisation and becomes fragile.
- Host-bounded. The Optimiser’s value-add lives inside the host’s spread. Narrower spread, smaller gain.
Specific to this Euler design:
- No per-supplier fairness. Pro-rata sharing among matched lenders; no FIFO, no counter-party preferences.
- No internal liveness. A 100%-utilised lender has no internal exit. They depend on Layer 8’s escape valve, which is only as liquid as
eUSDC-variable.