Section B · Technical Core · Primary

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:

#InvariantWhy it matters
I1Total supply ≥ total borrow at all times (modulo socialized bad debt)If borrow exceeds supply, suppliers cannot withdraw — protocol-level insolvency.
I2No position is borrowing more than allowed by its collateral × LLTV × oracle priceThe "healthy positions stay healthy" property after every transaction.
I3Sum of position debts ≡ total borrow (in shares)Bookkeeping invariant — share/asset conversions are consistent.
I4Interest accrues monotonically; no negative ratesAccumulator math never decreases.
I5A user cannot affect another user's position without explicit authorizationThe "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.
Pre-liquidations and gap LTVs

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:

FamilyHow it worksUsed by
Kinked linearTwo 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 lowMorpho Blue's adaptive IRM
Jump rate / stepDiscrete tiersOlder 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

AxisIsolated (single-collateral per market)Cross-collateral pool
Risk isolationStrong — bad debt in one market does not touch othersWeak — one toxic asset can spread
Capital efficiencyLower — same collateral cannot back multiple borrows simultaneouslyHigher — single account, multiple collaterals
Curation costLow — markets can be permissionless; vaults curateHigh — every listing is a protocol-wide risk decision
UXFragmented — many small marketsUnified — one borrowing account
ExampleMorpho Blue, Euler v2 vault-pairAave v3, Compound v2
The senior framing

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

StyleMechanismProsCons
Fixed-incentive partialLiquidator repays up to close-factor debt at fixed LI%Simple, predictableCrude — incentive does not adapt to severity
Full liquidationWhole position closed at onceCleans bad debt fastPunitive to borrowers; concentrates MEV competition
Dutch auctionDiscount starts at 0, increases over time/blocks until a keeper bidsPrice-discovers the LI; MEV-resistantSlow — large positions may not be cleared in time
Pre-liquidationBelow pre-LLTV but above LLTV; smaller, opt-in, cheaperSmooths cascades; better for the borrowerAdds complexity; requires user opt-in or vault curation
Gradual / continuousPosition auto-deleverages over time when unhealthyBorrower-friendly, smoother on cascadesVery protocol-specific; harder to keep gas low
MEV reality

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:

  1. 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.
  2. Treasury absorbs. A protocol-owned insurance fund repays the shortfall. Aave's Safety Module is a partial example.
  3. 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:

  1. Singleton. One contract, all markets in a mapping keyed by keccak256(abi.encode(params)).
  2. Permissionless markets. Anyone can create a market by calling createMarket(params); the LLTV, IRM, oracle, and tokens are baked in and immutable.
  3. Accrue first. Every state-changing function calls accrueInterest at the top, before any other state read.
  4. Round in protocol favor. Down on supply→shares; up on shares→assets on repay.
  5. Liquidate when unhealthy. No close-factor restriction necessary in an isolated design — full or partial allowed.
  6. Socialize on zero collateral. If after a liquidation the position has zero collateral but residual debt, write it down.
  7. 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.
The winning pattern

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.