You Could Have Invented Morpho Optimizer
A layer-by-layer derivation of Morpho-Compound and Morpho-AaveV2.
Morpho Optimizer was the original Morpho product, deployed in June 2022 as a peer-to-peer matching layer on top of Aave V2 and Compound V2. It pulled in over $2B in deposits in just over a year, but the design had a structural ceiling: it could only ever capture a fraction of the pool it sat on, and it inherited every limitation of the underlying protocol. That recognition is what drove the team to build Morpho Blue in early 2024 — a standalone lending primitive with no underlying pool to optimize against. The Optimizer itself was wound down through 2025: front-end retired in September, on-chain deprecation finalized in December.
What lending pools actually do
Suppose you want to build an on-chain lending market for USDC. The simplest possible design is a shared pot:
- Anyone can deposit USDC into the pot. They become a supplier.
- Anyone can lock collateral (say, ETH) and pull USDC out of the pot. They become a borrower.
- Suppliers earn interest from borrowers. Borrowers pay interest into the pot.
This is, mechanically, what Aave V2 and Compound are. The “pot” is a single contract per asset. Every supplier’s USDC is fungibly mixed with every other supplier’s USDC. Every borrower draws from the same pile.
Now we hit the first practical question. How does a supplier know how much interest they’ve earned? Updating every supplier’s balance every block is gas-prohibitive — there could be tens of thousands of them.
The trick that Compound (and Aave) invented is the index (Compound calls it exchangeRate; Aave calls it liquidityIndex). It’s one number per market that grows monotonically over time at the supply APY:
poolSupplyIndex over time:
t=0 1.000e18
t=1y 1.030e18 (3% APY)
t=2y 1.061e18
t=3y 1.092e18
When you supply 100 USDC at index = 1.000e18, the contract records your scaled balance as 100 / 1.000 = 100. That number never changes until you supply or withdraw again.
A year later, the index has grown to 1.030. Your underlying claim is now:
underlying = scaledBalance × currentIndex
= 100 × 1.030
= 103 USDC
You earned 3 USDC and nobody touched your balance. The pool just ticked one number. Borrowers work the same way in reverse: a global borrowIndex grows at the borrow APY, and each borrower’s debt is a scaled “principal” that’s multiplied by the current index to get what they owe.
Mental model to lock in. Everywhere in DeFi lending, you’ll see:
// scale ⇄ underlying conversion
underlying = scaledBalance × index;
scaledBalance = underlying / index;This is how a single global counter does interest accrual for everyone.
Where the spread comes from
Here’s the part that motivates everything else. Open any Aave or Compound dashboard and you’ll see something like:
USDC market:
Supply APY: 3.0% ← what suppliers earn
Borrow APY: 5.0% ← what borrowers pay
Why the 2 percentage point gap? Where does the money go?
The arithmetic
Imagine the USDC pool has $100 of supply and $80 borrowed. Utilization = 80%. Suppose the borrow APY is 5%. Borrowers pay 80 × 5% = $4 in interest per year.
That $4 has to be divided across all $100 of supply, not just the borrowed portion. So:
supplyAPY ≈ borrowAPY × utilization × (1 − reserveFactor)
= 5% × 80% × (1 − 10%)
= 3.6%
The 10% reserveFactor is a tax kept by the protocol treasury. The rest of the gap is structural: the 20% of supply that’s idle earns nothing, but its yield gets averaged in with the 80% that’s productive.
This spread is structural. Even if the protocol set reserveFactor = 0, suppliers would still earn less than borrowers pay — because some supply must stay idle to honor withdrawals, and you can’t run a pool at 100% utilization without locking everyone out.
The thing to notice
If you’re Alice (supplying) and Bob (borrowing), and you could find each other directly, you could split the spread. Pick any rate between 3.6% and 5.0%, and:
- Alice earns more than 3.6% (her pool APY).
- Bob pays less than 5.0% (his pool APY).
Both win. The “buffer for idle liquidity” disappears when there’s a real counterparty for each dollar.
The matchmaker idea
Here’s the clever bit. We don’t want to replace Aave or Compound. They already solved hard problems (oracles, collateral parameters, liquidations, an audited risk framework). We just want to sit on top of the pool and pair people up opportunistically.
The simplest possible matchmaker:
function supply(amount):
if there's an existing pool borrower for this asset:
match them with us
repay their pool debt on their behalf
record: we are matched to them, both at the P2P rate
else:
just deposit to the pool like normal
function borrow(amount):
if there's an existing pool supplier:
match us with them
withdraw their pool supply on their behalf, give it to us
record: we are matched to them
else:
just borrow from the pool
That’s the kernel. The matchmaker promises:
- If matched: both sides get a better rate than the pool.
- If not matched: use the pool, same outcome as going directly.
Now let’s start adding the layers it actually needs.
Two buckets per user
A user might be partially matched and partially on the pool. If Alice supplies $100 and only one $60 borrower exists, $60 of Alice is matched (earning the P2P rate) and $40 is parked on the pool (earning the pool rate). We need to track both.
So we split each user’s position into two scaled balances per market:
struct SupplyBalance {
uint256 onPool; // scaled against poolSupplyIndex
uint256 inP2P; // scaled against p2pSupplyIndex
}
struct BorrowBalance {
uint256 onPool; // scaled against poolBorrowIndex
uint256 inP2P; // scaled against p2pBorrowIndex
}
Alice’s underlying claim is now:
underlying = onPool × poolSupplyIndex + inP2P × p2pSupplyIndex
╰────────────────────╯ ╰─────────────────────╯
parked-on-Compound part matched-to-Bob part
grows at the pool rate grows at the P2P rate
Total supply across the market is the sum of onPool balances (which represent cTokens Morpho actually holds in Compound) plus the sum of inP2P balances (which represent claims on matched borrowers).
Indexes, not balances
We can’t update every matched user’s balance every block when interest accrues. Same problem Compound solved with exchangeRate — we need our own version.
We already have two indexes from the pool (poolSupplyIndex, poolBorrowIndex) that handle interest for the onPool portions. For the inP2P portions, we introduce two more:
| Index | Owned by | Grows at |
|---|---|---|
poolSupplyIndex | Compound/Aave | pool supply APY (e.g. 3.6%) |
poolBorrowIndex | Compound/Aave | pool borrow APY (e.g. 5.0%) |
p2pSupplyIndex | Morpho | P2P supply APY (e.g. 4.2%) |
p2pBorrowIndex | Morpho | P2P borrow APY (e.g. 4.4%) |
Morpho stores them per market. Every time anyone interacts with a market, Morpho reads the current pool indexes from Compound, computes the growth since the last update, and advances its own P2P indexes by the matching amount:
function updateP2PIndexes(market):
poolSupplyGrowth = ICToken(market).exchangeRateCurrent() / lastPoolSupplyIndex
poolBorrowGrowth = ICToken(market).borrowIndex() / lastPoolBorrowIndex
p2pGrowth = weightedAverage(poolSupplyGrowth, poolBorrowGrowth, cursor)
// the cursor is a per-market knob — Chapter 9
p2pSupplyIndex *= p2pGrowth // (minus reserve factor tweaks)
p2pBorrowIndex *= p2pGrowth
snapshotPoolIndexes()
Two consequences worth chewing on:
- Morpho has no rate model of its own. It reads the pool’s rates and computes a number in between. If Compound’s borrow rate spikes, Morpho’s P2P rate follows automatically.
- Indexes only tick on touch. Between actions, the indexes are stale — but so are
lastPoolIndexes, so the growth ratio next time covers the entire missing interval. Conservation works out.
A sorted linked list for matching
“Find an existing pool borrower to match against” sounds simple. In Solidity, you can’t iterate a mapping. We need a data structure that lets us iterate users in a market and mutate cheaply.
Maintain four lists per market:
mapping(market => DoubleLinkedList) suppliersInP2P;
mapping(market => DoubleLinkedList) suppliersOnPool;
mapping(market => DoubleLinkedList) borrowersInP2P;
mapping(market => DoubleLinkedList) borrowersOnPool;
Why a doubly-linked list? Because when a user gets matched or unmatched, they need to be removed from one list and inserted into another. A doubly-linked list does removal in O(1) given a node pointer.
Why sorted by amount (largest first)? Because when a new borrower wants $1000 and the pool has many small suppliers, you want to start with the biggest one. Matching against one $800 supplier and one $200 supplier = 2 iterations. Matching against a thousand $1 suppliers = 1000 iterations (or worse, OOG revert).
Insertion into a sorted list is O(n) in general, so Morpho caps it: there’s a constant maxSortedUsers, and once a user’s position passes that threshold of being-larger-than-others, they get inserted at the head region; smaller positions accumulate near the tail. It’s a pragmatic compromise — perfect sorting would cost too much gas at insert time.
What happens when someone leaves
Supplying and borrowing are easy. The hard part is exit. Suppose:
- Alice supplied $100. No borrowers existed, so her $100 is all
onPool. - Bob deposits collateral, borrows $50. Morpho matches him with Alice: Alice now has
onPool=$50, inP2P=$50, Bob hasinP2P=$50. - Alice calls
withdraw(100).
Alice’s first $50 is easy — just redeem her cTokens from Compound. But her second $50 is in P2P, matched to Bob. What do we do with Bob?
Option A: Find a replacement supplier (promote)
Search suppliersOnPool for someone who can take Alice’s slot. If Carol has $200 sitting on the pool, we can:
- Move $50 of Carol’s position from
onPooltoinP2P. (Pure accounting flip.) - Redeem $50 of Carol’s cTokens from Compound. Hand the cash to Alice.
Bob is still in P2P, but now matched against Carol instead of Alice. Bob doesn’t even know.
Option B: Demote Bob to the pool
If no replacement supplier is available, we have to push Bob back to the pool. “Demoting” Bob means:
- Bob’s
inP2Pdecreases by $50 (accounting). - Bob’s
onPoolincreases by $50 (accounting). - Morpho calls
borrow()on Compound, pulling $50 of USDC out. (Bob’s collateral is already on Compound, so Morpho’s contract-level account is collateralized.) - That $50 goes to Alice.
Bob’s underlying debt is unchanged — he still owes $50. What changed is which bucket holds it, and therefore which rate it accrues at. Bob’s effective borrow rate just rose from the P2P rate to the (worse) pool rate. From his perspective, his “discount evaporated.” Tough.
“Demoting” and “promoting” are pure accounting moves — they only flip onPool and inP2P on a single user. The actual borrow / repay / supply / withdraw calls to Compound happen separately, in bulk, after the accounting is settled.
Gas budgets and the delta
What if Alice was matched against 1000 small Bobs (each owing $1)? Demoting all of them in one transaction would cost more gas than the block limit allows. Without a fallback, the entire withdraw reverts and Alice is stuck.
The fix has two parts:
- Cap the work. Every matching loop carries a
maxGasForMatchingbudget. When the budget is exhausted, the loop stops cleanly, returning how much it accomplished. - Track the unfinished business. The residual — Bobs we wanted to demote but couldn’t — is accounted for in a special variable called the delta.
The delta state
struct Delta {
uint256 p2pSupplyDelta; // P2P-tagged supply that's parked on the pool
uint256 p2pBorrowDelta; // P2P-tagged borrow that's actually borrowed from the pool
uint256 p2pSupplyAmount; // total P2P supply, scaled
uint256 p2pBorrowAmount; // total P2P borrow, scaled
}
A p2pBorrowDelta = $700 means: “We claim there’s $700 of P2P borrow in our accounting, but it’s really backed by a Morpho-owned pool borrow, not by a matched supplier.”
Worked example
Indexes all equal 1.0 for simplicity. Suppose the gas budget allows exactly 3 demotion iterations.
Setup. Alice supplied $1000, got matched piece-by-piece against ten borrowers Bob1…Bob10, each borrowing $100.
Alice: onPool=0, inP2P=1000 (fully matched)
Bob1: onPool=0, inP2P=100
Bob2: onPool=0, inP2P=100
...
Bob10: onPool=0, inP2P=100
p2pSupplyAmount = 1000
p2pBorrowAmount = 1000
p2pSupplyDelta = 0
p2pBorrowDelta = 0
Morpho's Compound account:
Supplied: 5 ETH (sum of Bobs' collateral)
Borrowed: $0
cUSDC held: $0 ← Alice's USDC was all redeemed when Bobs borrowed
Alice calls withdraw(1000). The contract works through its cascade:
- Withdraw from Alice’s own onPool. She has 0. Skip.
- Eat p2pSupplyDelta. Is 0. Skip.
- Promote a pool supplier into Alice’s slot.
suppliersOnPoolis empty. Skip. - Demote P2P borrowers. Loop starts. Demote Bob1 ($100), Bob2 ($100), Bob3 ($100). Gas budget runs out. Loop exits with
unmatched = $300.
Residual = $1000 − $300 = $700. Code path:
if (unmatched < remainingToWithdraw) {
delta.p2pBorrowDelta += (1000 - 300) / poolBorrowIndex; // += 700
}
delta.p2pSupplyAmount -= 1000 / p2pSupplyIndex; // → 0
delta.p2pBorrowAmount -= 300 / p2pBorrowIndex; // → 700
_borrowFromPool(market, 1000); // Morpho borrows the full $1000 from Compound
transfer(alice, 1000);
End state:
Alice: gone
Bob1: onPool=100, inP2P=0 (cleanly demoted)
Bob2: onPool=100, inP2P=0
Bob3: onPool=100, inP2P=0
Bob4: onPool=0, inP2P=100 ← STILL TAGGED P2P (they don't know yet)
Bob5: onPool=0, inP2P=100
...
Bob10: onPool=0, inP2P=100
p2pSupplyAmount = 0
p2pBorrowAmount = 700 ← Bob4..Bob10 still here
p2pSupplyDelta = 0
p2pBorrowDelta = 700 ← THE DELTA: $700 of phantom P2P backed by pool borrow
Morpho's Compound account:
Supplied: 5 ETH
Borrowed: $1000 USDC (= $300 backing Bob1..Bob3 + $700 backing the delta)
What the delta does to interest rates
Bob4..Bob10 think they’re in P2P. Their stored inP2P is unchanged. But economically, their debt is backed by a pool borrow. So when the indexes next update, Morpho blends:
shareOfDelta = (p2pBorrowDelta × poolBorrowIndex) / (p2pBorrowAmount × p2pBorrowIndex)
= (700 × 1.0) / (700 × 1.0)
= 1.0 ← 100% delta-covered right now
p2pBorrowIndex grows at:
(1 − shareOfDelta) × p2pGrowthFactor + shareOfDelta × poolGrowthFactor
= 0 × p2pGrowthFactor + 1.0 × poolGrowthFactor
= pool borrow growth
So Bob4..Bob10 effectively pay the pool borrow rate while the delta is uncleared. No one had to touch their individual positions — the index does the work.
How the delta heals
The next time a supplier deposits, the supply flow has a step that says “first, use this new supply to pay down the pool borrow that backs the delta”:
if (delta.p2pBorrowDelta > 0) {
// pay down the delta with the new supply
repay_to_pool(min(newSupply, deltaInUnderlying));
delta.p2pBorrowDelta -= ...;
}
If Eve supplies $500, the delta shrinks from $700 to $200. Bob4..Bob10’s effective rate shifts from “100% pool rate” toward “71% P2P, 29% pool”. The mismatch resolves itself proportionally as new traffic arrives.
The delta is not a loss. Morpho is never insolvent. The delta is a bookkeeping marker for “we couldn’t finish the matching in one tx — the index will price it as a pool position until cleaned up.”
The symmetric case: repay creates a supply delta
If a P2P borrower repays a huge amount and Morpho can’t find enough P2P suppliers to demote (gas-bounded), the freed supply gets parked on the pool, and a p2pSupplyDelta is recorded. Same mechanics, opposite direction. P2P suppliers’ rates drift toward the pool supply rate until the next borrower absorbs the delta.
Why deltas only appear on exit, not on entry
| Operation | Failure mode | Delta? |
|---|---|---|
| Supply | No borrowers to match → falls through to pool deposit | No |
| Borrow | No suppliers to match → falls through to pool borrow | No |
| Withdraw | Can’t fully demote matched borrowers | p2pBorrowDelta |
| Repay | Can’t fully demote matched suppliers | p2pSupplyDelta |
Entry operations always have a clean fallback (the pool itself). Exit operations must somehow disconnect existing matches, and disconnecting can be capped by gas — that’s the unique problem the delta solves.
Choosing the P2P rate
We’ve been waving at “the P2P rate” as something between pool supply and pool borrow. Where exactly? And how is the small remaining gap (if any) divided?
The cursor
Each market has a parameter p2pIndexCursor in basis points (0 to 10000). It picks the point between the pool supply rate and the pool borrow rate where the “fair” P2P rate sits:
Production values are usually around 3333 (one-third), slightly favoring borrowers, on the theory that borrowers initiate matches more often and incentivizing them grows the pie.
The reserve factor
The protocol’s DAO needs revenue to fund ops and pay for incidents. So there’s a tiny extra spread, controlled by reserveFactor:
p2pSupplyRate = p2pRate − (p2pRate − poolSupplyRate) × reserveFactor
p2pBorrowRate = p2pRate + (poolBorrowRate − p2pRate) × reserveFactor
If reserveFactor = 1000 (10%), suppliers earn slightly less than borrowers pay; the 10% sliver of the surplus accrues to Morpho. Visualized:
pool borrow ─●─ 5.00%
│
▼
─ ─ ─ ─ ─ p2pBorrowRate 4.16% ← Bobs pay this
│
─ ─ ─ ─ ─ midpoint (cursor) 4.07%
│
─ ─ ─ ─ ─ p2pSupplyRate 3.99% ← Alices earn this
▲
│
pool supply ─●─ 3.60%
reserve factor tax = p2pBorrowRate − p2pSupplyRate ≈ 0.17%
Still way better than the 1.4% pool spread.
Risk and liquidation, inherited
We’ve built a yield-optimization layer. We have NOT built collateral parameters, oracles, liquidation thresholds, or close factors. Reinventing those is a year of careful work.
Morpho’s solution: don’t reinvent them. Inherit them.
- Collateral factor (LTV) per asset: read from Compound’s Comptroller / Aave’s risk params.
- Oracle for collateral prices: Compound’s oracle / Aave’s oracle.
- Liquidation threshold and incentive: the pool’s values.
- Close factor (max % of debt repayable per liquidation): from the pool.
Two benefits:
- No new risk surface. A Morpho user’s risk profile is identical to using the pool directly. Same liquidation triggers, same protections.
- Composability with the pool. Morpho is itself one big user of the pool. The collateral it deposits there (on its users’ behalf) must satisfy the pool’s risk model, so it would have to use these parameters anyway.
Liquidation logic just wraps repay + withdraw internally:
function liquidate(borrower, collateralMarket, debtMarket, amount):
require(_isLiquidatable(borrower)); // using pool's oracle + LTVs
require(amount <= debt × pool.closeFactor());
_repayLogic(debtMarket, liquidator, borrower, amount);
amountToSeize = amount × pool.liquidationIncentive() × debtPrice / collateralPrice;
_withdrawLogic(collateralMarket, amountToSeize, borrower, liquidator);
The liquidator’s repayment goes through the same priority-of-sources logic that any repay would (pool first, then P2P with matching/demotion). The seizure goes through the same withdraw flow. Liquidations are first-class but require no special code path.
The full picture
Now that you’ve added each layer, the architecture should feel inevitable. The user-facing contract Morpho.sol is a thin proxy that delegatecalls into specialized implementation contracts (the logic is too big to fit in one contract under the 24 KB code-size limit):
The supply flow, one more time
updateP2PIndexes(market)— accrue interest on stored balances.- If a
p2pBorrowDeltaexists, use the new supply to pay it down first. - Walk
borrowersOnPool(largest first), promoting them toinP2Puntil either the supply is fully absorbed or the matching gas budget is exhausted. - Any leftover supply: deposit to the pool the boring way.
- Update the user’s position in the sorted linked lists.
The withdraw flow, one more time
updateP2PIndexes.- Check the user isn’t being made undercollateralized.
- Withdraw from the user’s
onPoolfirst (cheap). - Decrement
inP2P; eat anyp2pSupplyDelta(cash already parked on the pool waiting for someone to claim it). - Promote a pool supplier into the freed slot (replacement).
- If still short, demote matched borrowers to the pool (gas-bounded).
- Whatever remains creates a
p2pBorrowDelta; Morpho borrows it from the pool. - Transfer the underlying to the user.
The summary table
| Concept | Why it exists |
|---|---|
Two scaled balances (onPool + inP2P) | Users can be partially matched. |
| Two index pairs (pool + P2P) | Two different rates accrue to two different buckets without touching individual balances. |
| Sorted doubly-linked lists | Fast iteration in matching; O(1) move between buckets. |
maxGasForMatching | Caps work so transactions can’t be DOSed by long lists. |
p2pSupplyDelta, p2pBorrowDelta | Track residual work the matching engine didn’t finish, so the index can blend rates honestly until cleared. |
p2pIndexCursor | Picks where in the spread the P2P rate sits. |
reserveFactor | DAO’s revenue share of the surplus. |
| Inherited collateral / oracle / liquidation | No new risk surface; composability with the pool. |
Trade-offs you’ve signed up for
- Higher gas per operation. Matching, list maintenance, and delta accounting add work beyond what the underlying pool needs. Small positions may not justify the gas premium.
- No COMP / AAVE rewards on matched positions. Pool rewards accrue only to
onPoolusers. P2P users trade rewards for better base rates. - Worst-case behavior reverts to pool rates. Under stress (mass exit, no matches available), big deltas push effective rates toward the pool. Morpho is “boost in normal times,” not “boost no matter what.”
- Extra smart-contract surface. Strictly more code than vanilla pool usage. Heavily audited but a real consideration.
Closing thought
Every layer of Morpho is the answer to a question the previous layer couldn’t answer. Two buckets answered “what if a user is partially matched?” Indexes answered “how do we accrue without touching balances?” Linked lists answered “how do we find counterparties without iterating mappings?” Gas budgets answered “how do we bound long loops?” Deltas answered “what happens to the residual?” Cursor and reserve factor answered “where exactly is the P2P rate, and who pays for the protocol?” Inheriting risk parameters answered “do we have to redo collateral and liquidation?”
That’s the whole optimizer. Re-read the questions in order — if you can recite the answer to each one without the page, you understand Morpho-Compound and Morpho-AaveV2 well enough to read the source.