Lending Protocol Mechanism Design
The deep dive interviewers will spend the most time on. Derive a lending market from first principles, then argue every trade-off.
Lending invariants — the things that must always hold
Before any line of Solidity, a senior protocol engineer thinks in invariants. A minimal lending market has perhaps five:
| # | Invariant | Why it matters |
|---|---|---|
| I1 | Total supply ≥ total borrow at all times (modulo socialized bad debt) | If borrow exceeds supply, suppliers cannot withdraw — protocol-level insolvency. |
| I2 | No position is borrowing more than allowed by its collateral × LLTV × oracle price | The "healthy positions stay healthy" property after every transaction. |
| I3 | Sum of position debts ≡ total borrow (in shares) | Bookkeeping invariant — share/asset conversions are consistent. |
| I4 | Interest accrues monotonically; no negative rates | Accumulator math never decreases. |
| I5 | A user cannot affect another user's position without explicit authorization | The "permissions" invariant — modulo liquidations on unhealthy positions. |
If you can name these unprompted in an interview, you have set the frame for the whole conversation.
LTV, LLTV, and the health factor
Three numbers govern collateralization:
- LTV (Loan-to-Value) — instantaneous ratio of borrow value to collateral value.
- LLTV / Liquidation LTV — the threshold above which a position becomes liquidatable. Sometimes called the liquidation threshold.
- Max borrow LTV — the threshold above which the protocol refuses to let a user borrow more. Sometimes equal to LLTV (simpler), sometimes lower (buffered, safer).
Healthy means: borrow × price ≤ collateral × LLTV. In WAD:
function isHealthy(Position memory p, MarketParams memory params, uint256 price)
internal pure returns (bool)
{
// collateral.value * LLTV >= borrow.assets
uint256 collateralValue = (p.collateral * price) / ORACLE_SCALE; // 1e18-scaled
uint256 maxBorrow = (collateralValue * params.lltv) / WAD;
return p.borrowAssets <= maxBorrow;
}
Subtleties:
- The price units have to be exactly right. Most production code uses an oracle scale that aligns price units with token decimals so the math reduces to one division. Get this wrong and the protocol breaks across decimal mismatches.
- Health is computed after interest accrual. If you check health before accruing, you may let a borrow happen on a position that is already underwater.
- Rounding direction: when computing max borrow from collateral, round down. When computing collateral required for a given borrow, round up.
Several modern designs add a pre-LLTV below the LLTV. Between pre-LLTV and LLTV, the position is liquidatable at a discounted incentive (or via a Dutch auction). The intuition: smaller, gentler liquidations close the gap before a market-wide cascade.
Interest rate models
The IRM maps utilization (= total borrow / total supply) to a per-second borrow rate. The supply rate is then derived as borrowRate × utilization × (1 - reserveFactor).
The canonical curve is the kinked linear:
// Aave-style kinked IRM
function borrowRate(uint256 U) internal view returns (uint256 r) {
if (U <= OPTIMAL_U) {
// slope1 segment
r = BASE_RATE + (U * SLOPE_1) / OPTIMAL_U;
} else {
// slope2 segment — steep above the kink
uint256 excess = U - OPTIMAL_U;
r = BASE_RATE + SLOPE_1 + (excess * SLOPE_2) / (WAD - OPTIMAL_U);
}
}
Three production IRM families:
| Family | How it works | Used by |
|---|---|---|
| Kinked linear | Two slopes, breakpoint at an optimal utilization (often 80-90%) | Aave, Compound, Spark |
| Adaptive (PID) | Targets a utilization; rate drifts up if utilization is high, down if low | Morpho Blue's adaptive IRM |
| Jump rate / step | Discrete tiers | Older Compound v2 |
// Adaptive curve — simplified. Rate moves towards equilibrium driven by deviation
// from target utilization. dr/dt = k * (U - U_target).
function newRate(uint256 currentRate, uint256 U, uint256 U_target, uint256 dt)
internal pure returns (uint256)
{
int256 deviation = int256(U) - int256(U_target); // signed
int256 delta = (deviation * int256(K) * int256(dt)) / int256(WAD);
int256 newR = int256(currentRate) + delta;
if (newR < int256(MIN_RATE)) newR = int256(MIN_RATE);
if (newR > int256(MAX_RATE)) newR = int256(MAX_RATE);
return uint256(newR);
}
Two senior-level questions:
- Why a kink? Suppliers need an incentive to leave headroom for withdrawals. The steep slope above kink punishes high utilization and pulls in fresh supply.
- Why adaptive? Different assets have different "ideal" rates, and ideal varies with macro conditions. Adaptive IRMs let the market discover the rate rather than the protocol picking it.
Market topology — isolated vs cross-collateral
| Axis | Isolated (single-collateral per market) | Cross-collateral pool |
|---|---|---|
| Risk isolation | Strong — bad debt in one market does not touch others | Weak — one toxic asset can spread |
| Capital efficiency | Lower — same collateral cannot back multiple borrows simultaneously | Higher — single account, multiple collaterals |
| Curation cost | Low — markets can be permissionless; vaults curate | High — every listing is a protocol-wide risk decision |
| UX | Fragmented — many small markets | Unified — one borrowing account |
| Example | Morpho Blue, Euler v2 vault-pair | Aave v3, Compound v2 |
Isolated markets push risk curation off the core protocol and into a market for curators (ERC-4626 vaults that allocate across markets). The core stays minimal and immutable; risk is a separate, competitive layer above. This is the single most important architectural shift in lending in the last few years.
Liquidation mechanics
When a position falls below LLTV, anyone (the "liquidator") may repay some or all of its debt in exchange for collateral at a discount. Three knobs:
- Close factor — the maximum fraction of debt repayable in a single liquidation (e.g., 50% in Aave, 100% in some isolated designs).
- Liquidation Incentive (LI) — the discount on collateral the liquidator receives. Often 5-10%, sometimes a function of how unhealthy the position is.
- Bonus split — how the incentive is divided between liquidator and protocol (e.g., 95% to liquidator, 5% to a treasury).
function liquidate(
Market storage m,
Position storage p,
uint256 seizedCollateral,
uint256 price
) internal returns (uint256 repaid) {
if (isHealthy(p, m.params, price)) revert HealthyPosition();
// Liquidator receives `seizedCollateral`, must repay corresponding debt.
// debtToRepay = seizedCollateral * price / (1 + LI) (in WAD)
uint256 collateralValue = (seizedCollateral * price) / ORACLE_SCALE;
repaid = (collateralValue * WAD) / (WAD + m.params.liquidationIncentive);
if (repaid > p.borrowAssets) revert OverRepay();
p.collateral -= seizedCollateral;
p.borrowAssets -= repaid;
m.totalBorrow -= repaid;
// Pull repay, push collateral
m.loanToken.safeTransferFrom(msg.sender, address(this), repaid);
m.collateralToken.safeTransfer(msg.sender, seizedCollateral);
}
Liquidation variants
| Style | Mechanism | Pros | Cons |
|---|---|---|---|
| Fixed-incentive partial | Liquidator repays up to close-factor debt at fixed LI% | Simple, predictable | Crude — incentive does not adapt to severity |
| Full liquidation | Whole position closed at once | Cleans bad debt fast | Punitive to borrowers; concentrates MEV competition |
| Dutch auction | Discount starts at 0, increases over time/blocks until a keeper bids | Price-discovers the LI; MEV-resistant | Slow — large positions may not be cleared in time |
| Pre-liquidation | Below pre-LLTV but above LLTV; smaller, opt-in, cheaper | Smooths cascades; better for the borrower | Adds complexity; requires user opt-in or vault curation |
| Gradual / continuous | Position auto-deleverages over time when unhealthy | Borrower-friendly, smoother on cascades | Very protocol-specific; harder to keep gas low |
In practice, liquidations are a heavily MEV-competitive market. The first searcher to land a profitable liquidation gets it. Fixed-LI partials are won by the highest priority-fee bidder; Dutch auctions move the competition from priority-gas to timing patience; pre-liquidations can be auctioned to a curated keeper. Design the auction knowing a sophisticated searcher market will play it.
Bad debt and socialization
If a position goes underwater faster than liquidators can clear it (price gaps down through LLTV in a single block; oracle outage; insufficient liquidity), there is bad debt: borrowed assets that the collateral cannot cover.
Three responses:
- Socialize. Reduce every supplier's claim proportionally. Suppliers wake up to a haircut. This is the Maker "smip" / Morpho Blue approach: bad debt is realized immediately as a downward write of the supply index.
- Treasury absorbs. A protocol-owned insurance fund repays the shortfall. Aave's Safety Module is a partial example.
- Defer. Treat the debt as written-off and continue. Borrowers and suppliers find out at withdrawal time. This is the worst option — it generates bank-run dynamics.
// Bad-debt socialization — write down totalSupplyAssets to reflect loss.
function _socializeBadDebt(Market storage m, uint256 position) internal {
uint256 collateral = positions[position].collateral;
if (collateral != 0) return; // only if collateral is zero
uint256 outstandingDebt = positions[position].borrowAssets;
m.totalBorrowAssets -= outstandingDebt;
m.totalSupplyAssets -= outstandingDebt; // suppliers eat the loss
positions[position].borrowAssets = 0;
emit BadDebtSocialized(position, outstandingDebt);
}
This is one of those design choices where there is no clean answer. Pure socialization is honest but causes user pain on rare events. Treasury absorption requires the treasury to be capitalized and managed. Deferral is a fiction that eventually breaks.
Worked example — derive a minimal lending market
An interviewer says: "Whiteboard a minimal lending market. State, invariants, transitions." A senior answer:
struct MarketParams {
address loanToken;
address collateralToken;
address oracle;
address irm;
uint256 lltv; // WAD, e.g. 0.86e18
}
struct Market {
uint128 totalSupplyAssets;
uint128 totalSupplyShares;
uint128 totalBorrowAssets;
uint128 totalBorrowShares;
uint128 lastUpdate;
uint128 fee; // accumulated to treasury, WAD
}
struct Position {
uint256 supplyShares;
uint128 borrowShares;
uint128 collateral;
}
// Transitions (each takes MarketParams + acts on Market and Position)
function supply(MarketParams calldata m, uint256 assets) external;
function withdraw(MarketParams calldata m, uint256 shares) external;
function supplyCollateral(MarketParams calldata m, uint256 amount) external;
function withdrawCollateral(MarketParams calldata m, uint256 amount) external;
function borrow(MarketParams calldata m, uint256 assets) external;
function repay(MarketParams calldata m, uint256 shares) external;
function liquidate(MarketParams calldata m, address borrower, uint256 seized) external;
function accrueInterest(MarketParams calldata m) public;
The walk-through to give:
- Singleton. One contract, all markets in a mapping keyed by
keccak256(abi.encode(params)). - Permissionless markets. Anyone can create a market by calling
createMarket(params); the LLTV, IRM, oracle, and tokens are baked in and immutable. - Accrue first. Every state-changing function calls
accrueInterestat the top, before any other state read. - Round in protocol favor. Down on supply→shares; up on shares→assets on repay.
- Liquidate when unhealthy. No close-factor restriction necessary in an isolated design — full or partial allowed.
- Socialize on zero collateral. If after a liquidation the position has zero collateral but residual debt, write it down.
- Callbacks for composability. Borrow / liquidate accept an optional callback that fires before the transfer-in, enabling "flash" semantics.
Bonus: if asked to extend it for fixed-rate loans, the senior answer outlines a maturity-bucketed overlay (suppliers commit liquidity to a tenor; rates fix at deal time) or a perpetual fixed-rate via IRS overlay rather than reworking the core.
Trade-offs you should be able to defend either side of
- Singleton vs factory: singleton wins on gas, composability, invariants. Factory wins on per-market upgradability and isolation of bugs.
- Permissionless markets vs governed listings: permissionless wins on speed and minimal core; governed wins on user safety for casual users.
- Variable rate vs fixed rate: variable wins on liquidity efficiency; fixed wins on user predictability and matches off-chain finance.
- Fixed LI vs Dutch auction: fixed wins on simplicity and gas; Dutch wins on price discovery and MEV resistance.
- Full liquidation vs close-factor partial: full simplifies the math; partial is gentler on borrowers.
For each row, the senior answer is the same shape: "Here's the case for A. Here's the case for B. We picked / I would pick [X] because [the dominant constraint], but I'd reach for [Y] if [the constraint flipped]." That's the level. Not "X is right." Not "they're both fine." But: here are the regimes in which each one wins.