Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Part 2 — Module 9: Capstone — Decentralized Multi-Collateral Stablecoin

Difficulty: Advanced

Estimated reading time: ~55 minutes | Exercises: ~15-20 hours (open-ended)


📚 Table of Contents

Overview & Design Philosophy

Architecture Design

Core CDP Engine

Vault Share Collateral Pricing

Dutch Auction Liquidation

Flash Mint

Testing & Hardening

Building & Wrap Up


💡 Overview & Design Philosophy

💡 Concept: Why a Stablecoin Capstone

You’ve spent 8 modules building DeFi primitives in isolation — an AMM here, a lending pool there, a vault somewhere else. A stablecoin protocol is where they all converge. It touches every primitive you’ve learned:

  • Token mechanics (M1) — SafeERC20 for collateral handling, decimal normalization across token types
  • AMMs (M2) — liquidation collateral sold via DEX, slippage determines liquidation economics
  • Oracles (M3) — Chainlink price feeds drive health factor calculations
  • Lending math (M4) — health factors, collateralization ratios, interest accrual indexes
  • Flash loans (M5) — flash mint for the stablecoin itself (atomic mint + use + burn)
  • CDPs (M6) — the core engine: normalized debt, rate accumulators, vault safety checks, liquidation
  • Vaults (M7) — ERC-4626 vault shares as a collateral type, share pricing, inflation attack awareness
  • Security (M8) — invariant testing across the whole system, oracle manipulation defense

Module 6’s key takeaway said it: “Stablecoins are the ultimate integration test.” This capstone is that test.

This is not a guided exercise. You built scaffolded exercises in M1-M8. This is different — you’ll design the architecture, make trade-offs, and own every decision. The module provides architectural guidance, design considerations, and deep dives on new concepts. The implementation is yours.

💡 Concept: The Stablecoin Landscape: Where Your Protocol Sits

Before designing, understand the field you’re entering.

ProtocolCollateralLiquidationGovernancePeg Mechanism
DAI (MakerDAO)Multi (ETH, USDC, RWAs)Dutch auction (Clipper)MKR governancePSM + DSR
LUSD (Liquity V1)ETH onlyStability PoolNone (immutable)Redemptions
GHO (Aave)Aave aTokensAave liquidationAave governanceFacilitators
crvUSD (Curve)wstETH, WBTC, etc.LLAMMA (soft liq.)veCRV governancePegKeeper
Your protocolETH + ERC-4626 sharesDutch auctionNone (immutable)Flash mint arbitrage

Your protocol’s design position: immutable like Liquity, multi-collateral like MakerDAO, with vault shares as collateral like GHO uses aTokens, and flash mint for peg stability. Each of these choices has a rationale you’ll be able to articulate in an interview.

The 2025-2026 landscape context: The stablecoin space continues to evolve. Liquity V2 moved away from full immutability toward user-set interest rates. Ethena’s USDe pioneered delta-neutral backing (crypto collateral + perpetual short hedge). RWA-backed stablecoins are growing but face regulatory pressure. Understanding the full spectrum — from fully decentralized (your protocol, Liquity V1) to fully centralized (USDC) — is what interviewers expect. Your protocol sits at the decentralized end, and you should be able to articulate why that position has both strengths (censorship resistance, no counterparty risk) and limitations (capital inefficiency, no adaptability).

Historical lessons baked into your design:

  • Black Thursday (March 2020): MakerDAO’s English auction liquidations (Liquidations 1.0 via Flipper) failed — network congestion during the crash spiked gas prices, preventing keepers from submitting competitive bids. Zero-bid auctions caused ~$8M in bad debt. This is why MakerDAO moved to Dutch auctions (Liquidations 2.0 via Dog + Clipper), and why your protocol uses Dutch auctions from day one.
  • LUNA/UST collapse (May 2022): Algorithmic stablecoins without real collateral can enter a death spiral. Your protocol is fully collateral-backed — no algorithmic peg mechanism.
  • MakerDAO centralization creep: DAI became 50%+ USDC-backed through the PSM, undermining decentralization. Your protocol accepts only crypto-native collateral — no fiat-backed assets.

📖 Study these: Before you start building, spend time reading MakerDAO dss (the canonical CDP protocol) and Liquity (the immutable alternative). Your protocol borrows from both philosophies.

💡 Concept: Design Principles: Immutable, Permissionless, Crypto-Native

Three principles define every design decision in your protocol.

1. Immutable — No admin keys, no parameter changes

Once deployed, the contracts govern themselves by their rules. No multisig can change LTV ratios, no governance vote can adjust stability fees, no emergency admin can pause the system.

Why: eliminates the entire governance attack surface. No flash loan governance attacks (Module 8). No delegate corruption. No regulatory capture via governance tokens.

Trade-off: can’t fix bugs, can’t adapt to market changes. If your parameters are wrong, you deploy a new version. Liquity V1 proved this model works — but Liquity V2 moved away from it because the rigidity became a limitation. For this capstone, immutability is the right choice: it’s the harder design challenge (you must get parameters right the first time) and the more impressive portfolio piece.

2. Permissionless — Anyone can participate in every role

  • Anyone can open a CDP and mint stablecoins
  • Anyone can liquidate an underwater position
  • Anyone can use flash mint
  • No whitelists, no KYC gates, no privileged roles

3. Crypto-native collateral only — No fiat-backed assets

ETH and ERC-4626 vault shares. No USDC, no RWAs, no tokens that a centralized entity can freeze. This eliminates centralization risk — the controversy with DAI where 50%+ of its collateral was USDC-backed.

Trade-off: harder to maintain peg without fiat-backed collateral. This is why flash mint matters — it provides the arbitrage mechanism that keeps the peg without relying on a PSM backed by centralized stablecoins.

🔗 Cross-Module Prerequisite Map

Before you start, verify you’re comfortable with these concepts from earlier modules. Each one directly maps to a component you’ll build.

ModuleConceptWhere You’ll Use It
M1SafeERC20, decimal normalizationAll token transfers; multi-decimal health factor
M3Chainlink integration, staleness checksPriceFeed.sol — ETH/USD with safety checks
M4Health factor, liquidation thresholdStablecoinEngine.sol — vault safety check
M4Interest rate math (compound index)Stability fee accrual in the engine
M5ERC-3156 flash loan interfaceStablecoin.sol — flash mint implementation
M6Normalized debt (art × rate), frobEngine’s deposit/mint/repay flow
M6Rate accumulator, rpow(), drip()Stability fee compounding per collateral type
M6Dutch auction (bark/take, SimpleDog)DutchAuctionLiquidator.sol
M6WAD/RAY/RAD precision scalesAll arithmetic throughout the protocol
M7ERC-4626, convertToAssets()Vault share collateral pricing
M7Inflation attack defenseRate cap for vault share pricing
M8Invariant testing methodology5-invariant test suite with handler
M8Oracle manipulation awarenessPriceFeed defensive design

If any of these feel fuzzy, revisit the module before starting. This capstone assumes you’ve internalized them.

📋 Key Takeaways: Overview & Design Philosophy

After this section, you should be able to:

  • Explain why a multi-collateral stablecoin is the ideal Part 2 integration project: it touches token mechanics, oracles, lending math, liquidation, flash loans, vault pricing, and security
  • Position your protocol in the stablecoin landscape vs DAI, LUSD, GHO, crvUSD and articulate the design trade-off: maximizing decentralization (immutable, permissionless, crypto-native) at the cost of adaptability
  • Map 13 specific prerequisite concepts from 7 modules to the stablecoin components where they’ll be applied
Check your understanding
  • Why a stablecoin capstone: A multi-collateral stablecoin system integrates nearly every DeFi primitive studied in Part 2 — token mechanics (ERC-20 + flash), oracle integration, lending-style health factor math, liquidation auctions, flash loan patterns, vault share pricing, and security invariants — into a single cohesive protocol.
  • Design positioning: Compared to DAI (governance-controlled, adaptable) and LUSD (immutable, ETH-only), your protocol targets maximum decentralization and immutability (no governance, no PSM) while supporting multiple collateral types including ERC-4626 vault shares.
  • Prerequisite mapping: Each capstone component draws on specific module knowledge — e.g., the Engine uses M4’s rate accumulator pattern, the PriceFeed uses M3’s Chainlink integration with staleness checks, the Liquidator uses M6’s Dutch auction pattern, and the Stablecoin uses M5’s flash loan callback pattern adapted for minting.

💡 Architecture Design

💡 Concept: Contract Structure: The 4 Core Contracts

Your protocol has four contracts with clear responsibilities and clean interfaces between them.

                         ┌─────────────────────┐
                         │  Stablecoin.sol      │
                         │  (ERC-20 + Flash)    │
                         └─────────┬───────────┘
                                   │ mint / burn
                         ┌─────────┴───────────┐
                         │ StablecoinEngine.sol │
                         │   (CDP Core Logic)   │
                         ├─────────────────────┤
                         │ • Vault storage      │
                         │ • Health factor      │
                         │ • Rate accumulator   │
                         │ • Deposit/Mint/etc   │
                         └──┬──────────────┬───┘
                            │              │
               ┌────────────┴──┐    ┌──────┴──────────────────┐
               │ PriceFeed.sol │    │ DutchAuctionLiquidator  │
               │ (Oracle Agg)  │    │ (MEV-resistant auctions)│
               └───────────────┘    └─────────────────────────┘

StablecoinEngine.sol — The core. Stores all vault state: collateral amounts, normalized debt per vault, rate accumulators and collateral configurations per collateral type. Handles the complete vault lifecycle: deposit collateral, mint stablecoin, repay debt, withdraw collateral, close vault. Calls PriceFeed for pricing, calls Stablecoin for mint/burn. Exposes view functions for health factor and liquidation eligibility that the Liquidator reads.

PriceFeed.sol — Oracle aggregation with two pricing paths. Path 1 (ETH): Chainlink ETH/USD with staleness check. Path 2 (vault shares): convertToAssets() to get underlying amount, then Chainlink price for the underlying, with rate cap protection against manipulation. Returns prices in a consistent decimal base.

DutchAuctionLiquidator.sol — Receives notification (or checks) that a vault is liquidatable. Starts an auction: collateral for sale at a declining price. Anyone can call buyCollateral() at the current price. Handles partial fills, refunds remaining collateral to vault owner when debt is covered, tracks bad debt when auctions don’t fully recover.

Stablecoin.sol — ERC-20 with two additional capabilities: (1) only the Engine can mint/burn for CDP operations, and (2) anyone can flash mint via the ERC-3156 interface. Clean, minimal token contract.

🔗 Connection: This 4-contract architecture mirrors MakerDAO’s separation (Vat = Engine, Spotter = PriceFeed, Dog+Clipper = Liquidator, Dai = Stablecoin) but simplified. You studied MakerDAO’s modular architecture in Module 6 — same philosophy, cleaner boundaries.

💡 Concept: Core Data Structures

These are the key structs you’ll design. Think carefully about what goes where — per-vault vs per-collateral-type vs global.

Per-vault state:

struct Vault {
    uint256 collateralAmount;    // [WAD] collateral deposited
    uint256 normalizedDebt;      // [WAD] debt / rate at time of borrow
    // Actual debt = normalizedDebt × rateAccumulator
}

🔗 Connection: This is exactly M6’s ink (collateral) and art (normalized debt) from the Vat. The actual debt = art × rate pattern you implemented in SimpleVat’s frob().

Per-collateral-type configuration:

struct CollateralConfig {
    address token;                // ERC-20 address (WETH or ERC-4626 vault)
    address priceFeed;            // Chainlink feed for this collateral's underlying
    bool isVaultToken;            // true = ERC-4626 (needs two-step pricing)
    uint256 liquidationThreshold; // [BPS] e.g., 8250 = 82.5%
    uint256 liquidationBonus;     // [BPS] e.g., 500 = 5%
    uint256 debtCeiling;          // [WAD] max stablecoin mintable against this type
    uint256 rateAccumulator;      // [RAY] starts at 1e27, grows per-second
    uint256 stabilityFeeRate;     // [RAY] per-second compound rate
    uint256 lastUpdateTime;       // timestamp of last drip
    uint256 totalNormalizedDebt;  // [WAD] sum of all vaults' normalizedDebt for this type
    uint8 tokenDecimals;          // cached decimals of the collateral token itself
    uint8 underlyingDecimals;     // for vault tokens: decimals of the underlying asset (ignored for non-vault)
}

Design considerations:

  • Why normalizedDebt instead of actual debt? Same reason as MakerDAO’s art — you update one global rateAccumulator instead of touching every vault’s debt individually. You built this in M6’s SimpleJug.
  • Why isVaultToken flag? The pricing path differs: ETH uses one Chainlink lookup, vault shares need convertToAssets() + Chainlink for the underlying. One flag, two code paths.
  • Why tokenDecimals cached? Gas. You’ll call decimal normalization on every health factor check. Calling ERC20(token).decimals() every time costs ~2,600 gas per SLOAD. Caching saves this on the hot path.

💡 Concept: Design Decisions You’ll Make

These are real architectural choices with trade-offs. Think through each one before coding. There’s no single right answer — what matters is that you can explain why you chose what you chose.

Decision 1: WAD/RAY precision or simpler scheme?

  • WAD (10^18) + RAY (10^27): Battle-tested. MakerDAO uses it. Maximum precision for per-second compounding — a rate of 2% annually is 1000000000627937192491029810 in RAY. You already worked with this in M6.
    • Pro: Proven, precise over long time periods.
    • Con: Verbose, easy to mix up WAD and RAY in the same expression.
  • All WAD (10^18): Simpler, but loses precision for very small per-second rates.
    • Pro: One scale, fewer conversion bugs.
    • Con: Rate precision may drift over months/years.

Decision 2: Liquidation trigger — push vs pull?

  • Pull (recommended): The Liquidator checks the Engine (isLiquidatable(user)) and initiates the auction. Keepers call the Liquidator directly.
    • Pro: Simple, clear separation of concerns. MakerDAO’s Dog does this.
  • Push: The Engine notifies the Liquidator when a vault becomes unhealthy.
    • Con: Who triggers the Engine to check? You still need keepers.

Decision 3: Bad debt handling

When a Dutch auction expires without fully covering the debt, someone must eat the loss.

  • Track as protocol debt: Accumulate bad debt in a global variable. It exists as unbacked stablecoin in circulation. Stability fees can gradually offset it (if the protocol generates surplus).
    • Pro: Simple, transparent. MakerDAO’s sin (system debt) works this way.
  • Socialize across holders: Effectively devalue the stablecoin by adjusting backing ratio.
    • Pro: Automatically resolves. Con: Breaks the $1 peg expectation.
  • Stability pool (Liquity model): Depositors absorb bad debt in exchange for liquidation collateral.
    • Pro: Elegant. Con: Significant additional complexity.

Decision 4: Flash mint fee — zero or nonzero?

  • Zero fee: Maximizes arbitrage incentive for peg maintenance. If the stablecoin trades at $1.01, even a $1 profit opportunity will attract arbitrageurs. MakerDAO’s DssFlash charges 0.
    • Pro: Strongest peg stability. Con: No revenue from flash mint.
  • Nonzero fee (e.g., 0.05%): Revenue source, but reduces the arbitrage window. The stablecoin can trade at $1.00 ± fee before arbitrage kicks in.
    • Pro: Revenue. Con: Wider peg band.

Decision 5: One vault per user per collateral type, or multiple vaults?

  • One vault per (user, collateralType): Simpler storage (mapping(address => mapping(bytes32 => Vault))). User can only have one position per collateral type.
    • Pro: Simple, gas efficient. Liquity does this.
  • Multiple vaults with IDs: User can open many positions. More flexible but more complex.
    • Pro: Can manage risk separately. Con: More storage, more complexity.

Decision 6: Collateral held in Engine or separate Join adapters?

  • Engine holds collateral directly: Simpler. depositCollateral() transfers tokens to the Engine contract.
    • Pro: Fewer contracts, fewer external calls.
  • Join adapters (MakerDAO model): Separate contracts (GemJoin) handle token-specific logic. The Engine only tracks internal accounting.
    • Pro: Engine stays token-agnostic. Adding a new collateral type just means deploying a new Join.
    • Con: More contracts, more calls. Overkill for 2 collateral types.

Think through these before writing code. Your answers shape the entire architecture. Write them down — they become your Architecture Decision Record for the portfolio.

💡 Concept: Deployment & Authorization

Your 4 contracts have mutual dependencies. Think about deployment order and how contracts authorize each other:

  • Stablecoin needs to know the Engine address (only Engine can mint/burn for CDPs)
  • Engine needs to know PriceFeed and Stablecoin addresses
  • Liquidator needs permission to call Engine’s seizeCollateral()
  • PriceFeed is standalone (no dependencies on other protocol contracts)

Since the protocol is immutable (no setters), these addresses must be set at deployment. The concrete pattern: deploy via a deployer script that deploys PriceFeed first (no dependencies), pre-computes the Engine address via CREATE2, deploys Stablecoin with that pre-computed Engine address as a constructor arg, then deploys Engine (at the pre-computed address) and Liquidator with all addresses known. Alternatively, use a factory contract that deploys all four in a single transaction, passing addresses between constructor calls.

This is a real production concern — MakerDAO’s deployment scripts handle complex interdependencies across 10+ contracts. Your 4-contract system is simpler, but the authorization wiring still needs to be correct.

💡 Concept: Storage Layout Considerations

For gas optimization on the hot path (health factor checks happen on every mint/withdraw), think about how CollateralConfig fields pack into storage slots:

  • Fields read together on the hot path: rateAccumulator (RAY — uint256, full slot), totalNormalizedDebt (WAD — uint256, full slot), liquidationThreshold and liquidationBonus (BPS values — could fit as uint16 in a packed slot with tokenDecimals, underlyingDecimals, and isVaultToken)
  • Fields read less often: debtCeiling, stabilityFeeRate, lastUpdateTime

Packing BPS values as uint16 (max 65,535 — more than enough for basis points) saves SLOADs on the hot path. This is the same optimization pattern Aave V3 uses in its reserve configuration bitmap (M4).

📋 Key Takeaways: Architecture Design

After this section, you should be able to:

  • Sketch the 4-contract architecture from memory (StablecoinEngine, PriceFeed, DutchAuctionLiquidator, Stablecoin) with clear responsibilities and data flow between them
  • Define the core data structures (Vault per-position, CollateralConfig per-type) and explain storage layout choices for gas-efficient health factor checks
  • Articulate a position on each of the 6 design decisions with trade-off reasoning, and explain the deployment order with cross-contract authorization
Check your understanding
  • Four-contract architecture: StablecoinEngine (core accounting, vault lifecycle, health factor checks), PriceFeed (Chainlink integration + vault share pricing with rate cap), DutchAuctionLiquidator (separate contract calling Engine’s seizeCollateral()), Stablecoin (ERC-20 with authorized mint/burn and ERC-3156 flash mint).
  • Data structures and storage: Vault struct holds per-position state (collateral amount, normalized debt). CollateralConfig holds per-type parameters (rate accumulator, thresholds, debt ceiling). Pack BPS values as uint16 to reduce SLOADs on the health factor hot path — same optimization pattern as Aave V3’s reserve configuration bitmap.
  • Design decisions and deployment: Each decision (e.g., immutable vs upgradeable, fixed vs adjustable parameters) has trade-offs that should be justified. Deployment order matters because contracts need cross-references: deploy PriceFeed first (no dependencies), pre-compute Engine’s address via CREATE2, deploy Stablecoin with the pre-computed Engine address (so only Engine can mint/burn), then deploy Engine and Liquidator with all addresses known.

🧭 Checkpoint — Before Moving On: Can you sketch the 4-contract architecture from memory? Can you name the 6 design decisions and articulate a preference (with rationale) for each? If you can’t, re-read the Architecture Design material above — the architecture IS the project, and changing it mid-build is expensive.


💡 Core CDP Engine

💡 Concept: The StablecoinEngine Contract

This is where the core logic lives. The Engine manages all vaults, tracks all debt, and enforces all safety rules.

External functions:

// Vault lifecycle
function depositCollateral(bytes32 collateralType, uint256 amount) external;
function withdrawCollateral(bytes32 collateralType, uint256 amount) external;
function mintStablecoin(bytes32 collateralType, uint256 amount) external;
function repayStablecoin(bytes32 collateralType, uint256 amount) external;

// Rate accumulator
function drip(bytes32 collateralType) external;

// View functions (used by Liquidator and externally)
function getHealthFactor(address user, bytes32 collateralType) external view returns (uint256);
function isLiquidatable(address user, bytes32 collateralType) external view returns (bool);
function getVaultInfo(address user, bytes32 collateralType) external view returns (uint256 collateral, uint256 debt);

// Liquidation support (called by Liquidator only)
function seizeCollateral(address user, bytes32 collateralType, uint256 collateralAmount, uint256 debtToCover) external;

🔗 Connection: Compare this interface to M6’s SimpleVat. depositCollateral + mintStablecoin together are frob() with positive dink and dart. seizeCollateral is grab(). Same patterns, cleaner API.

🔍 Deep Dive: Health Factor with Multi-Decimal Normalization

Health factor is the core solvency check. You implemented it in M4 (LendingPool) and saw it in M6 (Vat’s safety check: ink × spot ≥ art × rate). The new challenge here: your protocol has two collateral types with different pricing paths and different decimals, and the health factor must handle both correctly.

The formula:

Health Factor = (collateral_value_usd × liquidation_threshold) / actual_debt_usd

Where:

  • collateral_value_usd depends on collateral type (ETH vs vault shares — different pricing)
  • actual_debt = normalizedDebt × rateAccumulator
  • HF ≥ 1.0 → safe. HF < 1.0 → liquidatable.

Numeric walkthrough — ETH collateral:

Given:
  collateral    = 10 ETH            (18 decimals → 10e18)
  normalizedDebt = 15,000            (18 decimals → 15_000e18)
  rateAccumulator = 1.02e27          (RAY — 2% accumulated fees)
  ETH/USD price = $3,000             (Chainlink 8 decimals → 3000e8)
  liq. threshold = 82.5%             (BPS → 8250)

Step 1: Actual debt
  actualDebt = normalizedDebt × rateAccumulator / 1e27
             = 15_000e18 × 1.02e27 / 1e27
             = 15_300e18  (WAD)

Step 2: Collateral value in USD (normalize to 8 decimals)
  collateralUSD = collateral × ethPrice / 10^tokenDecimals
                = 10e18 × 3000e8 / 1e18
                = 30_000e8

Step 3: Debt value in USD (stablecoin = $1, 18 decimals)
  debtUSD = actualDebt × 1e8 / 1e18
          = 15_300e18 × 1e8 / 1e18
          = 15_300e8

Step 4: Health factor (scale to 1e18)
  HF = collateralUSD × liqThreshold × 1e18 / (debtUSD × 10000)
     = 30_000e8 × 8250 × 1e18 / (15_300e8 × 10000)
     = 1.617e18  (1.617 — healthy)

Numeric walkthrough — ERC-4626 vault share collateral:

Given:
  shares          = 100 vault shares  (18 decimals → 100e18)
  vault exchange  = 1 share = 1.05 WETH (vault has earned 5% yield)
  normalizedDebt  = 200,000            (18 decimals → 200_000e18)
  rateAccumulator = 1.01e27            (RAY)
  ETH/USD price   = $3,000             (Chainlink 8 decimals → 3000e8)
  liq. threshold  = 75%                (BPS → 7500)

Step 1: Actual debt
  actualDebt = 200_000e18 × 1.01e27 / 1e27 = 202_000e18

Step 2: Convert shares to underlying
  underlyingAmount = vault.convertToAssets(100e18) = 105e18 WETH

Step 3: Price underlying in USD
  collateralUSD = underlyingAmount × ethPrice / 10^underlyingDecimals
                = 105e18 × 3000e8 / 1e18
                = 315_000e8

Step 4: Debt value in USD
  debtUSD = 202_000e18 × 1e8 / 1e18 = 202_000e8

Step 5: Health factor
  HF = 315_000e8 × 7500 × 1e18 / (202_000e8 × 10000)
     = 1.170e18  (1.17 — healthy, but tighter than the ETH vault)

The pattern: Always track decimal counts explicitly at every step. Write them in comments during development. The most common integration bug is comparing values with different decimal bases.

🔗 Connection: You practiced this exact decimal normalization in M4’s health factor exercise. The addition here is the vault share pricing path (Step 2 above), which adds the convertToAssets() layer.

💡 Concept: Stability Fee Accrual via Rate Accumulator

Your stability fee system is the same pattern you built in M6’s SimpleJug. Each collateral type has its own rateAccumulator that grows per-second via compound interest.

The pattern:

function drip(bytes32 collateralType) external {
    CollateralConfig storage config = configs[collateralType];
    uint256 timeDelta = block.timestamp - config.lastUpdateTime;
    if (timeDelta == 0) return;

    // Per-second compounding: rate^timeDelta
    uint256 rateMultiplier = rpow(config.stabilityFeeRate, timeDelta, RAY);
    uint256 oldRate = config.rateAccumulator;
    uint256 newRate = oldRate * rateMultiplier / RAY;
    config.rateAccumulator = newRate;
    config.lastUpdateTime = block.timestamp;

    // Mint fee revenue to maintain the backing invariant
    // This is what MakerDAO's fold() does — increase surplus by the fee amount
    uint256 feeRevenue = config.totalNormalizedDebt * (newRate - oldRate) / RAY;
    if (feeRevenue > 0) {
        stablecoin.mint(surplus, feeRevenue);
    }
}

🔗 Connection: This IS SimpleJug.drip() with an important addition: minting fee revenue to a surplus address. In M6’s SimpleJug, drip() called vat.fold() which internally increased the Vat’s dai balance for vow. Your version achieves the same by minting ERC-20 stablecoin directly. Without this step, the Backing invariant (totalSupply == totalDebt + badDebt) breaks after the first fee accrual. You already built rpow() (exponentiation by squaring in assembly) in M6. Reuse or adapt that implementation.

Numeric example — rate accumulator growth over time:

For a 5% annual stability fee, the per-second rate in RAY is 1000000001547125957863212448 (~1.0 + 5%/year per second).

Day 0:   rateAccumulator = 1.000000000e27
Day 1:   rateAccumulator = 1.000133681e27  (vault with 10,000 normalizedDebt owes 10,001.34)
Day 7:   rateAccumulator = 1.000936140e27  (owes 10,009.36)
Day 30:  rateAccumulator = 1.004018202e27  (owes 10,040.18)
Day 365: rateAccumulator = 1.050000000e27  (owes 10,500.00 — exactly 5%)

Note: the daily values are slightly less than simple interest (5% / 365 = 0.01370%/day) because per-second compounding distributes interest differently than simple division. With compound interest, the rate per period is smaller but applied more frequently — the total converges to 5% at year-end, but intermediate values differ from principal × annualRate × daysFraction. The difference is negligible but verifiable — use this as a sanity check when testing your drip() implementation.

Two collateral types compound independently. If ETH-type was last dripped 30 days ago and vault-share-type was dripped 1 day ago, their rate accumulators will differ — each tracks its own accumulated fees.

Note on rpow() precision: MakerDAO’s rpow() uses floor rounding (rounds down). This means the rate accumulator slightly under-accrues over long periods. The effect is negligible in practice but worth knowing — it’s a conservative design choice that slightly favors borrowers.

When to call drip() — this is critical:

depositCollateral  → drip NOT needed (no debt change)
withdrawCollateral → drip NEEDED    (health factor uses current debt)
mintStablecoin     → drip NEEDED    (debt changes, must be current)
repayStablecoin    → drip NEEDED    (same reason)
liquidation check  → drip NEEDED    (health factor must use current debt)
seizeCollateral    → drip NEEDED    (debt settlement must be accurate)

The rule: drip before any operation that reads or modifies debt.

💡 Concept: The Vault Lifecycle

The complete lifecycle with what changes in storage at each step:

  depositCollateral            mintStablecoin
  ┌──────┐                     ┌──────┐
  │ User │ ──→ collateral ──→ │Engine│ ──→ stablecoin ──→ User
  │      │     to Engine       │      │     minted
  └──────┘                     └──────┘

  vault.collateralAmount += amount    vault.normalizedDebt += amount * RAY / rateAccumulator
  totalNormalizedDebt unchanged       totalNormalizedDebt += same
  tokens transferred IN               tokens minted to user
  NO health check needed              Health factor checked AFTER (must be ≥ 1.0)
  repayStablecoin              withdrawCollateral
  ┌──────┐                     ┌──────┐
  │ User │ ──→ stablecoin ──→ │Engine│ ──→ collateral ──→ User
  │      │     to burn         │      │     returned
  └──────┘                     └──────┘

  vault.normalizedDebt -= amount * RAY / rateAccumulator    vault.collateralAmount -= amount
  totalNormalizedDebt -= same                          totalNormalizedDebt unchanged
  tokens burned                                        tokens transferred OUT
  NO health check needed                               Health factor checked AFTER
  Liquidation path (when HF < 1.0):

  Liquidator detects HF < 1.0
       │
       ▼
  Start Dutch auction (DutchAuctionLiquidator)
       │
       ▼
  Bidder calls buyCollateral() at current price
       │
       ├──→ Engine.seizeCollateral(): reduce vault's collateral + debt
       ├──→ Stablecoin burned (debt repaid)
       └──→ Collateral transferred to bidder

⚠️ Common CDP Engine Mistakes

1. Decimal mismatch in health factor

// ❌ WRONG: mixing decimal bases
uint256 collateralUSD = collateral * ethPrice;     // 18 + 8 = 26 decimals
uint256 debtUSD = debt * stablecoinPrice;           // 18 + 8 = 26 decimals... or is it?
uint256 hf = collateralUSD / debtUSD;               // If debt is already in stablecoin (18 dec), this is 26 vs 18

// ✅ CORRECT: normalize to a common base at every step
uint256 collateralUSD = collateral * ethPrice / (10 ** tokenDecimals);  // → 8 decimals
uint256 debtUSD = actualDebt * 1e8 / 1e18;                              // → 8 decimals
uint256 hf = collateralUSD * 1e18 / debtUSD;                            // → 18 decimals

2. Not calling drip() before health factor check

// ❌ WRONG: rate accumulator is stale
function isLiquidatable(address user, bytes32 colType) external view returns (bool) {
    uint256 hf = _getHealthFactor(user, colType);  // uses stale rateAccumulator
    return hf < 1e18;
}

// ✅ CORRECT: use current rate (either drip first or calculate inline)
function isLiquidatable(address user, bytes32 colType) external view returns (bool) {
    uint256 currentRate = _getCurrentRate(colType);
    uint256 hf = _getHealthFactorWithRate(user, colType, currentRate);
    return hf < 1e18;
}

3. Forgetting to burn stablecoin on repay

// ❌ WRONG: reducing debt but not burning the stablecoin
function repayStablecoin(bytes32 colType, uint256 amount) external {
    Vault storage vault = vaults[msg.sender][colType];
    vault.normalizedDebt -= amount * RAY / configs[colType].rateAccumulator;
    // stablecoin is still in circulation, unbacked!
}

// ✅ CORRECT: burn the stablecoin as debt is reduced
function repayStablecoin(bytes32 colType, uint256 amount) external {
    _drip(colType);
    Vault storage vault = vaults[msg.sender][colType];
    uint256 normalizedAmount = amount * RAY / configs[colType].rateAccumulator;
    vault.normalizedDebt -= normalizedAmount;
    configs[colType].totalNormalizedDebt -= normalizedAmount;
    stablecoin.burn(msg.sender, amount);  // CRITICAL: remove from circulation
}

4. Stale rate accumulator on the wrong collateral type

// ❌ WRONG: dripping one type but operating on another
function mintStablecoin(bytes32 colType, uint256 amount) external {
    _drip(ETH_TYPE);  // oops — dripped ETH but minting against VAULT_SHARE_TYPE
}

// ✅ CORRECT: always drip the specific collateral type being operated on
function mintStablecoin(bytes32 colType, uint256 amount) external {
    _drip(colType);  // drip the correct type
}

📋 Key Takeaways: Core CDP Engine

After this section, you should be able to:

  • Implement the Engine’s 10 external functions and trace the vault lifecycle: open → deposit → draw → drip → repay → withdraw → liquidate
  • Compute a health factor with multi-decimal normalization for both ETH and vault share collateral, handling different decimal scales correctly
  • Explain when drip() must be called (every state change where debt accuracy matters) and implement the stability fee accumulator from M6’s pattern
Check your understanding
  • Engine’s 10 external functions: The vault lifecycle spans depositCollateral, withdrawCollateral, mintStablecoin, burnStablecoin, liquidate, plus supporting functions. Each state-changing function must enforce the health factor check after modification (CEI pattern) and call drip() before any debt calculation.
  • Multi-decimal health factor: ETH (18 decimals) and vault shares (variable decimals) require normalizing to a common scale before computing health factor. The formula is (collateralValue * liquidationThreshold) / debt >= 1, where collateralValue must account for the token’s decimals, the oracle’s decimals, and the precision scaling.
  • When to call drip(): Before every operation that reads or modifies debt — minting, burning, liquidation, health factor checks. If you skip drip(), the rate accumulator is stale and actual debt is underestimated, potentially allowing undercollateralized positions to appear safe. Always drip the specific collateral type being operated on, not a hardcoded type.

💡 Vault Share Collateral Pricing

🔍 Deep Dive: The Pricing Challenge

ETH is straightforward to price: one Chainlink lookup, done. ERC-4626 vault shares are fundamentally different — their value changes continuously as the vault earns yield.

The problem: A vault share’s price depends on two things:

  1. The vault’s exchange rate (convertToAssets()) — how many underlying tokens each share represents
  2. The underlying token’s USD price (Chainlink)

Both can change independently. The exchange rate changes as the vault earns yield (or suffers losses). The underlying price changes with the market. And crucially, the exchange rate can be manipulated via donation (you studied this in M7’s inflation attack).

The two pricing paths side by side:

ETH collateral (one step):
  ┌──────────┐    Chainlink     ┌───────────┐
  │ ETH amt  │ ──────────────→  │ USD value │
  │ (18 dec) │    ETH/USD       │ (8 dec)   │
  └──────────┘    (8 dec)       └───────────┘

ERC-4626 vault shares (two steps):
  ┌──────────┐  convertToAssets  ┌────────────┐   Chainlink    ┌───────────┐
  │ shares   │ ───────────────→  │ underlying │ ────────────→  │ USD value │
  │ (18 dec) │   exchange rate   │ (18 dec)   │   ETH/USD     │ (8 dec)   │
  └──────────┘                   └────────────┘   (8 dec)      └───────────┘
                  ▲ manipulable!

The extra step is where the complexity — and the security risk — lives.

💡 Concept: The Pricing Pipeline

Two-step pricing for vault shares:

Step 1: shares → underlying amount
  vault.convertToAssets(sharesAmount) → underlyingAmount

Step 2: underlying amount → USD value
  underlyingAmount × chainlinkPrice / 10^underlyingDecimals → USD value

Compared to ETH pricing (one step):

collateralAmount × chainlinkPrice / 10^18 → USD value

The Solidity for the PriceFeed might look like:

function getCollateralValueUSD(
    bytes32 collateralType,
    uint256 amount
) external view returns (uint256 valueUSD) {
    CollateralConfig memory config = engine.getConfig(collateralType);

    if (config.isVaultToken) {
        // Two-step: shares → underlying → USD
        // NOTE: convertToAssets returns underlying token decimals, NOT vault share decimals
        uint256 underlyingAmount = IERC4626(config.token).convertToAssets(amount);
        uint256 price = _getChainlinkPrice(config.priceFeed);
        valueUSD = underlyingAmount * price / (10 ** config.underlyingDecimals);
    } else {
        // One-step: amount → USD
        uint256 price = _getChainlinkPrice(config.priceFeed);
        valueUSD = amount * price / (10 ** config.tokenDecimals);
    }
}

⚠️ Manipulation Risk and Protection Strategies

The attack: An attacker donates tokens directly to the ERC-4626 vault, inflating totalAssets() without minting shares. This inflates convertToAssets() for all existing shares — including those used as collateral in your protocol.

Before donation:
  vault has 1000 WETH, 1000 shares → 1 share = 1.0 WETH

Attacker donates 500 WETH directly to vault:
  vault has 1500 WETH, 1000 shares → 1 share = 1.5 WETH (50% inflated!)

Attacker's 100 shares as collateral:
  Before: 100 × 1.0 × $3,000 = $300,000
  After:  100 × 1.5 × $3,000 = $450,000 (artificially inflated)

Attacker mints more stablecoin against the inflated collateral value.
Donation is reversed (attacker withdraws or gets liquidated elsewhere).
Protocol is left with under-collateralized debt.

🔗 Connection: This is the inflation attack from M7, but in a lending/CDP context rather than a vault deposit context. Same root cause, different exploitation path.

Three defense strategies:

Strategy 1: Rate cap (recommended)

Store the last known exchange rate. Enforce a maximum rate of increase (as a fixed BPS cap) per update. If the current rate exceeds the cap, use the capped rate. Update lastKnownRate whenever the current rate is within bounds.

lastKnownRate = 1.0 WETH per share
MAX_RATE_BPS = 100  (1% max increase per update)

maxRate = lastKnownRate × (10000 + MAX_RATE_BPS) / 10000 = 1.01

If convertToAssets() returns 1.5 (donation attack):
  safeRate = min(1.5, 1.01) = 1.01  ← attack neutralized
  lastKnownRate NOT updated (rate was capped)

If convertToAssets() returns 1.005 (legitimate yield):
  safeRate = min(1.005, 1.01) = 1.005  ← legitimate yield passes through
  lastKnownRate updated to 1.005 (for next check)
  • Pro: Simple, effective, low gas overhead. The code in Common Mistake 3 shows exactly this pattern.
  • Con: Legitimate large yield events (vault receiving liquidation proceeds) get capped temporarily. The cap must be tuned: too tight and legitimate yield is suppressed, too loose and donation attacks get through.

Strategy 2: Exchange rate TWAP

Accumulate exchange rate samples over time. Use the time-weighted average instead of the spot rate.

  • Pro: Smooths out manipulation naturally.
  • Con: More storage (cumulative samples), stale during rapid legitimate changes, more complex implementation.

Strategy 3: Require redemption before deposit

Don’t accept vault shares directly. Require users to redeem their vault shares for the underlying token, then deposit the underlying.

  • Pro: Eliminates manipulation entirely — you never call convertToAssets().
  • Con: Worse UX, users lose vault yield after depositing.

Recommendation for the capstone: Strategy 1 (rate cap). It’s the simplest to implement correctly, demonstrates awareness of the manipulation vector, and is the kind of defense an interviewer would want to discuss. Document the other strategies as considered alternatives in your Architecture Decision Record.

Common mistake: Using convertToAssets() without rate cap

// ❌ WRONG: directly trusting vault exchange rate (manipulable via donation)
uint256 underlyingAmount = IERC4626(vault).convertToAssets(shares);
uint256 value = underlyingAmount * price / 1e18;

// ✅ CORRECT: apply rate cap
uint256 currentRate = IERC4626(vault).convertToAssets(1e18);
uint256 maxRate = lastKnownRate * (10000 + MAX_RATE_BPS) / 10000;
uint256 safeRate = currentRate > maxRate ? maxRate : currentRate;
uint256 underlyingAmount = shares * safeRate / 1e18;
uint256 value = underlyingAmount * price / 1e18;

Design awareness: Vault share redemption limits during liquidation. ERC-4626 vaults can have withdrawal limits (maxWithdraw, maxRedeem). If the vault is at capacity or paused, auction bidders receive shares they can’t redeem. Options: accept vault shares as-is (bidder’s problem to redeem), redeem to underlying during the auction (adds gas, may fail), or document the risk and let the market price it into bids.

📋 Key Takeaways: Vault Share Collateral Pricing

After this section, you should be able to:

  • Trace the two-step pricing pipeline for vault share collateral: shares → convertToAssets() → underlying amount → Chainlink price → USD value
  • Explain the donation attack on vault share pricing (attacker inflates exchange rate to manipulate collateral valuation) and implement the rate cap defense with a numeric example
  • Compare the 3 defense strategies (rate cap, TWAP of exchange rate, internal accounting) and justify which you’d choose for your protocol
Check your understanding
  • Two-step pricing pipeline: First, convert shares to underlying amount via convertToAssets() (on-chain exchange rate). Second, price the underlying in USD via Chainlink. The final value is shares * exchangeRate * underlyingPrice, with appropriate decimal normalization at each step.
  • Donation attack and rate cap: An attacker donates assets directly to the vault, inflating convertToAssets() and making their collateral appear more valuable. The rate cap defense stores the last known exchange rate and limits increases to a maximum percentage per period (e.g., 10% per day). If the current rate exceeds the cap, use the capped rate instead.
  • Three defense strategies: Rate cap is simplest and sufficient for most cases. TWAP of the exchange rate smooths out manipulation but adds complexity and storage costs. Internal accounting (tracking assets via state, not balanceOf) is the most robust but requires vault cooperation. For an immutable protocol, rate cap offers the best simplicity-to-security ratio.

🧭 Checkpoint — Before Moving On: Take a piece of paper and trace a health factor calculation for vault share collateral end-to-end: shares → convertToAssets() → underlying amount → Chainlink price → USD value → HF formula. Include the rate cap check. If you can do this with concrete numbers (pick any), the pricing pipeline is solid. If the decimal normalization steps feel unclear, revisit the numeric walkthroughs above.


💡 Dutch Auction Liquidation

💡 Concept: Designing Your Liquidation System

You built a Dutch auction liquidator in M6’s SimpleDog exercise — bark() to start an auction and take() for bidders to buy collateral at the declining price. Your capstone liquidation system follows the same pattern, adapted for your protocol’s architecture.

The key differences from SimpleDog:

  • Your Liquidator is a separate contract that calls the Engine’s seizeCollateral()
  • You handle two collateral types (ETH and vault shares) with different pricing
  • You need bad debt tracking when auctions don’t fully recover
  • The auction interacts with your PriceFeed for the starting price

The flow:

1. Keeper calls Liquidator.liquidate(user, collateralType)
2. Liquidator calls Engine.isLiquidatable(user, collateralType) → must be true
3. Liquidator creates auction: {lot, tab, startPrice, startTime, user, collateralType}
4. Price declines over time according to decay function
5. Bidder calls Liquidator.buyCollateral(auctionId, maxAmount)
6. Liquidator calls Engine.seizeCollateral() to move collateral and reduce debt
7. Collateral transferred to bidder, stablecoin burned
8. If tab fully covered: remaining collateral refunded to vault owner
9. If auction expires without full coverage: remaining tab tracked as bad debt

🔍 Deep Dive: Choosing a Decay Function

The decay function determines how the auction price decreases over time. This directly affects MEV resistance and liquidation efficiency.

Option A: Linear decrease (what you built in SimpleDog)

price(t) = startPrice × (duration - elapsed) / duration
Price
  |●  $3,600 (startPrice = oracle × 1.20)
  | \
  |  \
  |   \
  |    \
  |     \
  |      ● $0 at duration end
  └────────────────── Time
        duration

Pro: Simple, predictable. You already have a reference implementation. Con: Linear decrease means the “sweet spot” for bidding is fairly narrow — price drops at the same rate throughout.

Option B: Exponential step decrease (MakerDAO’s approach)

price(t) = startPrice × (1 - step)^(elapsed / stepDuration)

Example with step = 1% every 90 seconds:

Price
  |●  $3,600
  |●● $3,564 (after 90s)
  | ●● $3,528 (after 180s)
  |  ●●● $3,493 (after 270s)
  |    ●●●●
  |        ●●●●●●●
  |               ●●●●●●●●●●●●
  └──────────────────────────────── Time

Pro: Rapid initial decrease (finds fair price faster), slows down near the floor (less risk of bad debt). More capital efficient. Con: Requires discrete step logic. MakerDAO’s StairstepExponentialDecrease in abaci.sol is a good reference.

Numeric example: startPrice = $3,600, step = 1%, stepDuration = 90s:

t=0s:    $3,600.00
t=90s:   $3,600 × 0.99^1 = $3,564.00
t=180s:  $3,600 × 0.99^2 = $3,528.36
t=270s:  $3,600 × 0.99^3 = $3,493.08
t=900s:  $3,600 × 0.99^10 = $3,255.78  (10 steps, ~9.6% decrease)
t=1800s: $3,600 × 0.99^20 = $2,944.46  (20 steps, ~18.2% decrease)

Option C: Continuous exponential

price(t) = startPrice × e^(-k × elapsed)

Pro: Smoothest curve. Con: Requires exp() approximation on-chain, extra gas.

Recommendation: Option A (linear) for a clean implementation, Option B (exponential step) as a stretch goal. Both work — the key is understanding why the choice matters for MEV resistance and capital efficiency.

📖 Study: MakerDAO’s abaci.sol implements all three decrease functions. Read LinearDecrease, StairstepExponentialDecrease, and ExponentialDecrease to see how a production protocol handles this choice.

💡 Concept: Partial Fills and Bad Debt

Partial fills: A bidder doesn’t have to buy all the collateral. They specify a maximum amount, pay the current price, and the auction continues with the remaining lot. When the cumulative payments cover the full debt (tab), the auction ends and surplus collateral returns to the vault owner.

Auction: 10 ETH lot, 15,000 stablecoin tab

Bidder A at t=300s: buys 4 ETH at $3,200 → pays 12,800 stablecoin
  Remaining: 6 ETH lot, 2,200 tab

Bidder B at t=450s: wants 0.75 ETH at $2,934 → owe = $2,200.50
  But tab is only 2,200, so: owe capped to 2,200, slice = 2,200 / 2,934 = 0.7498 ETH
  Auction complete. 6 - 0.7498 = 5.2502 ETH returned to original vault owner.

🔗 Connection: This is the same partial fill logic from M6’s SimpleDog take() function. The slice and owe calculations carry directly.

Bad debt: When the auction expires (price reaches zero or floor) without fully covering the tab:

Auction: 10 ETH lot, 20,000 stablecoin tab
Total bids only covered 17,000 stablecoin.
Bad debt: 3,000 stablecoin exists in circulation with no backing.

Your protocol must track this: totalBadDebt += uncoveredTab. This bad debt represents stablecoin in circulation that isn’t backed by collateral — a protocol-level liability. In MakerDAO, this is the sin (system debt) in the Vat. Stability fee revenue (surplus) can offset it over time: surplus > sin → system is solvent despite past bad debt.

🔍 Deep Dive: Full Liquidation Flow Walkthrough

End-to-end with concrete numbers, including the rate accumulator update that’s easy to forget.

Setup:
  Vault: 10 ETH collateral, normalizedDebt = 14,000e18
  rateAccumulator = 1.02e27 (2% accumulated fees)
  ETH/USD = $3,000 → drops to $1,700
  Liquidation threshold = 82.5% (8250 bps)
  Liquidation bonus = 5% (500 bps)
  Auction duration = 3600 seconds (1 hour)
  Start price buffer = 120% of oracle price

─── Step 1: Drip (update rate accumulator) ───
  Assume 1 day since last drip, stabilityFeeRate = 5% annual
  New rateAccumulator ≈ 1.020000137e27 (tiny increase — 1 day of 5% annual)
  For simplicity, keep 1.02e27

─── Step 2: Check health factor ───
  actualDebt = 14,000e18 × 1.02e27 / 1e27 = 14,280e18
  collateralUSD = 10e18 × 1700e8 / 1e18 = 17,000e8
  debtUSD = 14,280e18 × 1e8 / 1e18 = 14,280e8
  HF = 17,000e8 × 8250 × 1e18 / (14,280e8 × 10000) = 0.982e18

  HF < 1e18 → LIQUIDATABLE

─── Step 3: Start auction ───
  tab = actualDebt × (1 + liquidation bonus) = 14,280 × 1.05 = 14,994 stablecoin
  lot = 10 ETH
  startPrice = $1,700 × 1.20 = $2,040 per ETH

  Note: this "bonus as extra debt" approach means the bidder pays debt + bonus to the protocol.
  MakerDAO takes a different approach: the bidder buys collateral at a discount (bonus baked
  into the starting price). Both achieve the same economic result — the vault owner loses a
  penalty. Choose one and document why in your Architecture Decision Record.

─── Step 4: Bidder buys at t=600s (10 minutes) ───
  Linear price: $2,040 × (3600-600)/3600 = $1,700 per ETH
  Bidder wants all 10 ETH: cost = 10 × $1,700 = $17,000

  But tab is only 14,994. So:
  ETH needed to cover tab at $1,700: 14,994 / 1,700 = 8.82 ETH
  Bidder pays: 14,994 stablecoin (burned)
  Bidder receives: 8.82 ETH
  Refund to vault owner: 10 - 8.82 = 1.18 ETH

  Tab fully covered → auction complete
  Engine.seizeCollateral: vault's collateral = 0, vault's normalizedDebt = 0
  Bad debt: 0

  Backing invariant note: bidder burned 14,994 but vault debt was only 14,280.
  The 714 difference (liquidation bonus) is stablecoin burned beyond the debt —
  this reduces totalSupply more than debt decreased. To keep Invariant 2 balanced,
  the bonus portion must be routed to protocol surplus (or tracked as surplus revenue),
  NOT simply burned. Design this carefully — it mirrors the flash mint fee issue.

💡 Concept: Liquidation Economics: DEX Interaction

After a bidder receives collateral from the auction, they typically need to sell it on a DEX (AMM) to realize profit. This creates a connection to M2 that affects your protocol’s design:

  • Bidder profitability depends on DEX liquidity depth. If on-chain liquidity for your collateral type is thin, the slippage from selling seized collateral may exceed the auction discount. No one bids → bad debt accumulates.
  • Multiple simultaneous auctions can flood the DEX with sell pressure, worsening slippage for all bidders. This is a cascading risk.
  • The auction starting price buffer (e.g., 120%) and decay speed must be calibrated against realistic DEX slippage for your collateral types. ETH has deep liquidity; a niche ERC-4626 vault token may not.

This is why Aave governance evaluates on-chain liquidity depth before listing new collateral types — and why your choice of collateral (ETH + a vault wrapping a liquid asset like WETH) is a deliberate safety decision.

🔗 Connection: The slippage and AMM economics from M2 directly determine whether your liquidation system actually works in practice. A liquidation mechanism is only as reliable as the DEX liquidity behind it.

⚠️ Common Auction Mistake: Unhandled Bad Debt

// ❌ WRONG: assuming auction always covers tab
function buyCollateral(uint256 auctionId, uint256 maxAmount) external {
    // ... price calculation, transfer ...
    if (auction.lot == 0) {
        delete auctions[auctionId];  // auction done, but what if tab > 0 still?
    }
}

// ✅ CORRECT: track bad debt when auction expires or lot is exhausted
if (auction.lot == 0 || _auctionExpired(auctionId)) {
    if (auction.tab > 0) {
        totalBadDebt += auction.tab;  // acknowledge the loss
    }
    delete auctions[auctionId];
}

📋 Key Takeaways: Dutch Auction Liquidation

After this section, you should be able to:

  • Design a Dutch auction liquidation system with a separate Liquidator contract, choosing between the 3 decay functions (linear, exponential step, continuous) with trade-off reasoning
  • Implement partial fills (bidders buy portions, surplus returns to owner) and bad debt tracking (unrecovered tab as protocol liability)
  • Walk through a full liquidation numerically: drip → health check fails → auction starts → price decays → bidder bids → settlement, and calculate the bidder’s profit
  • Explain why Dutch auctions are MEV-resistant (no single optimal bid moment) and why MakerDAO moved from English to Dutch auctions after Black Thursday
Check your understanding
  • Decay function trade-offs: Linear decay is simplest but may overshoot fair price. Exponential step decay (MakerDAO’s StairstepExponentialDecrease) drops in discrete steps, giving bidders predictable windows. Continuous exponential decay provides the smoothest price curve but costs more gas to compute on-chain.
  • Partial fills and bad debt: Bidders can buy a portion of the lot — the auction updates remaining lot and tab, and returns surplus collateral to the vault owner if tab is fully covered before lot is exhausted. If the auction expires with remaining tab, that amount becomes bad debt tracked as a protocol-level liability.
  • Full liquidation walkthrough: drip() updates the rate accumulator, revealing the vault’s true debt exceeds its collateral threshold. startAuction() seizes collateral from the Engine and sets starting price above market. Price decays over time. A bidder calls bid() when price is attractive, paying stablecoins (burned) and receiving collateral. Profit = collateral market value minus stablecoins paid.
  • MEV resistance: In English auctions, the optimal strategy is to bid at the last moment (snipe), creating a single MEV-extractable moment. In Dutch auctions, the price continuously declines — bidding earlier means paying more, bidding later risks someone else taking the lot. There is no single optimal moment, spreading MEV across time.

💡 Flash Mint

🔍 Deep Dive: Flash Mint vs Flash Loan

Flash loans (M5) borrow existing tokens from a liquidity pool. Flash mint creates tokens from thin air. This is a fundamental difference:

Flash LoanFlash Mint
SourcePool liquidity (Aave, Balancer)Minted by the protocol
LimitPool balancetype(uint256).max — unlimited
Fee0.05% (Aave), 0 (Balancer)Protocol’s choice (0 or small)
ConstraintPool must have enough liquidityNone — protocol is the issuer
RepaymentReturn tokens to poolTokens burned at end of tx

🔗 Connection: Module 5 briefly mentioned MakerDAO’s DssFlash: “MakerDAO’s DssFlash module lets anyone mint unlimited DAI via flash loan — not from a pool, but minted from thin air and burned at the end.” Your Stablecoin.sol implements this exact pattern.

Why flash mint matters for your protocol:

Without governance and without a PSM (fiat-backed peg stability module), your protocol needs another peg mechanism. Flash mint provides it through arbitrage.

If your stablecoin trades above $1.00 on a DEX:

1. Flash mint 1,000,000 stablecoin (cost: 0)
2. Sell 1,000,000 stablecoin for $1,010,000 USDC on DEX
3. Buy 1,000,000 stablecoin for $1,000,000 on another venue
4. Repay flash mint
5. Profit: $10,000

This arbitrage pushes the price back toward $1.00. It requires zero capital and works atomically — anyone can do it, so the peg corrects quickly.

💡 Concept: ERC-3156 Adapted for Minting

The ERC-3156 interface you learned in M5 maps directly to flash minting. The Stablecoin token itself implements IERC3156FlashLender:

interface IERC3156FlashLender {
    function maxFlashLoan(address token) external view returns (uint256);
    function flashFee(address token, uint256 amount) external view returns (uint256);
    function flashLoan(
        IERC3156FlashBorrower receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool);
}

Key differences from a standard flash loan implementation:

  • maxFlashLoan() returns type(uint256).max — infinite liquidity since you’re minting, not lending from a pool
  • flashLoan() calls _mint() instead of transfer(), and _burn() instead of transferFrom()
  • The token lends itself — the Stablecoin contract is both the token and the flash lender

📖 Study: MakerDAO’s DssFlash and GHO’s GhoFlashMinter — two production implementations of flash mint.

⚠️ Security Considerations

1. Callback reentrancy

The flashLoan() function makes an external call to receiver.onFlashLoan(). During this callback, the flash-minted tokens exist in circulation — totalSupply() and balanceOf(receiver) are inflated.

Any external protocol that reads your stablecoin’s totalSupply() or a specific balanceOf() during the callback sees manipulated values. This is read-only reentrancy (M8).

Defense: reentrancy guard on flashLoan(). Also, be aware that your own Engine should not make decisions based on stablecoin totalSupply() — use internal accounting (totalNormalizedDebt × rateAccumulator).

2. Interaction with the Engine

During a flash mint callback, the receiver holds minted stablecoin. They could use it to:

  • Repay their own CDP debt (legitimate — this is actually useful for self-liquidation)
  • Deposit it somewhere to manipulate a price or balance

The first use case is a feature, not a bug — flash mint for self-liquidation is a valid pattern. The key invariant: at the end of the transaction, the flash-minted stablecoin is burned. Whatever happened during the callback is permanent (debt repayment, collateral withdrawal), but the flash-minted tokens themselves are gone.

3. Cross-contract reentrancy surface

Beyond flash mint, consider the broader reentrancy surface across your 4 contracts. When depositCollateral() or seizeCollateral() calls ERC20(token).transferFrom(), the collateral token could trigger a callback (if it’s ERC-777 or has transfer hooks). Your ERC-4626 vault token’s underlying could have such hooks. The Checks-Effects-Interactions pattern (update state before external calls) and a reentrancy guard on state-changing functions in the Engine protect against this.

4. Fee handling

If fee is zero: _burn(address(receiver), amount). Simpler, maximizes arbitrage incentive.

If you charge a fee: the receiver must hold amount + fee at the end of the callback. But you can’t simply _burn(amount + fee) — that destroys the fee, breaking Invariant 2 (Backing). The fee stablecoin wasn’t minted against any CDP debt, so burning it creates a gap between totalSupply and total debt. Instead: _burn(amount) to undo the flash mint, then transferFrom(receiver, surplus, fee) to route the fee to the protocol surplus address. This way the fee remains in circulation as protocol revenue, and the backing invariant holds.

Combining reentrancy protection (§1) and correct fee handling (§4):

// ❌ WRONG: no reentrancy protection, burns fee instead of routing to surplus
function flashLoan(...) external returns (bool) {
    _mint(address(receiver), amount);
    receiver.onFlashLoan(msg.sender, token, amount, fee, data);  // external call!
    _burn(address(receiver), amount + fee);  // destroys fee — breaks Backing invariant
    return true;
}

// ✅ CORRECT: reentrancy guard + proper fee routing
function flashLoan(...) external nonReentrant returns (bool) {
    _mint(address(receiver), amount);
    require(
        receiver.onFlashLoan(msg.sender, token, amount, fee, data) == CALLBACK_SUCCESS,
        "callback failed"
    );
    _burn(address(receiver), amount);  // burn only the minted amount
    if (fee > 0) {
        // Route fee to surplus — don't burn it (see §4 above)
        stablecoin.transferFrom(address(receiver), surplus, fee);
    }
    return true;
}

💡 Concept: Use Cases: Peg Stability and Beyond

  1. Peg arbitrage — described above. The primary peg maintenance mechanism.
  2. Self-liquidation — flash mint stablecoin → repay own debt → withdraw collateral → sell collateral for stablecoin → burn flash mint. Zero-capital exit from an underwater position.
  3. Liquidation funding — flash mint stablecoin → buy collateral from Dutch auction → sell collateral on DEX → burn flash mint + keep profit. This is the flash liquidation pattern from M4/M5, but using flash mint instead of flash loan.
  4. Composability — any protocol can integrate your stablecoin knowing that flash mint provides infinite temporary liquidity for atomic operations.

📋 Key Takeaways: Flash Mint

After this section, you should be able to:

  • Explain the difference between flash mint (mint from thin air, destroy at end) and flash loan (borrow from pool), and why flash mint is the peg mechanism for an immutable protocol without a PSM
  • Implement ERC-3156 adapted for minting and identify the security requirements: callback reentrancy protection, Engine interaction safety, fee handling
  • Compare 3 peg stability mechanisms (MakerDAO’s PSM, Liquity’s redemptions, your flash mint arbitrage) and articulate the trade-offs of each in an interview setting
Check your understanding
  • Flash mint vs flash loan: Flash loans borrow existing tokens from a pool (limited by pool liquidity). Flash mint creates tokens from nothing and destroys them at the end of the transaction (effectively unlimited). For an immutable protocol with no PSM, flash mint enables permissionless peg arbitrage: if stablecoin trades above $1, arbitrageurs flash mint, sell on DEX, buy back cheaper, and repay.
  • ERC-3156 adapted for minting: The Stablecoin contract implements flashLoan() by minting tokens to the borrower, calling onFlashLoan() on the receiver, then burning the principal + fee via transferFrom. Security requirements: reentrancy guard, verify the callback return value matches the expected hash, ensure fee collection is atomic with the burn.
  • Three peg mechanisms compared: PSM (MakerDAO) provides a hard peg floor/ceiling via 1:1 swaps but introduces centralization (USDC backing). Redemptions (Liquity) allow anyone to redeem stablecoins for collateral at face value, creating a hard floor but penalizing the riskiest vaults. Flash mint arbitrage is fully permissionless and requires no reserves, but only works when DEX liquidity exists and the deviation is large enough to cover gas + fees.

💡 Testing & Hardening

🔍 Deep Dive: The 5 Critical Invariants

Invariant testing (M8) is where you prove your protocol works under arbitrary sequences of operations. These 5 invariants are your protocol’s correctness properties.

Invariant 1: Solvency

Across all collateral types:
  sum(collateralValueUSD for ALL vaults) ≥ sum(actualDebt for ALL vaults) - totalBadDebt

Why: the system must never be insolvent (excluding acknowledged bad debt). If this breaks, your stablecoin is under-collateralized. Note: this is a global invariant — bad debt is tracked globally, not per collateral type, so the comparison must also be global.

Caveat: this invariant can be temporarily violated between a price drop (making vaults underwater) and the completion of liquidation auctions. In invariant testing, the handler should include liquidate and buyCollateral operations so the fuzzer can process liquidations and restore solvency as part of the operation sequence.

Handler operations that test it: depositCollateral, withdrawCollateral, mintStablecoin, repayStablecoin, moveOraclePrice, drip, liquidate, buyCollateral.

Invariant 2: Backing

stablecoin.totalSupply() == sum(vault.normalizedDebt × rateAccumulator for all vaults) + totalBadDebt

Why: every stablecoin in circulation must have a corresponding source — either an active CDP’s debt or acknowledged bad debt. If totalSupply > sum(debts) + badDebt, stablecoins were created without backing.

Important design implication: For this invariant to hold, drip() must mint new stablecoin to a surplus address when it increases rateAccumulator. Otherwise, debt grows (via compounding) but totalSupply stays the same — breaking the invariant after the very first fee accrual. This is what MakerDAO’s fold() does: it increases the Vat’s internal dai balance for vow (the surplus address) by the fee revenue amount. Your drip() must do the equivalent: stablecoin.mint(surplus, debtIncrease) where debtIncrease = totalNormalizedDebt × (newRate - oldRate) / RAY.

Why this stays balanced: drip() increases both sides of the equation in lockstep — rateAccumulator growth increases the right side (sum of debts), and the corresponding mint(surplus, feeRevenue) increases the left side (totalSupply) by the same amount. They stay in sync by construction.

Note: during a flash mint callback, totalSupply is temporarily inflated. Your invariant check should not run mid-flash-mint (the handler shouldn’t trigger a flash mint that’s still in progress when checking invariants).

Invariant 3: Accounting

For every collateral type:
  collateralConfig.totalNormalizedDebt == sum(vault.normalizedDebt for all vaults of that type)

Why: the per-type total must match the sum of individual vaults. If this breaks, the debt ceiling enforcement is wrong.

Invariant 4: Health

For every vault where healthFactor(user, collateralType) < 1.0:
  an auction is active for that vault

Why: unhealthy vaults should not persist without a liquidation in progress. If this breaks, your protocol is failing to protect itself. In practice, this invariant may temporarily fail after a moveOraclePrice handler call makes vaults underwater before the fuzzer calls liquidate. To handle this: either check the invariant only after a liquidate call has been given a chance to run, or relax the invariant to allow a bounded number of unliquidated unhealthy vaults (the fuzzer should eventually process them).

Invariant 5: Conservation

For every collateral type:
  ERC20(token).balanceOf(engine) + ERC20(token).balanceOf(liquidator)
    == sum(vault.collateralAmount for that type) + collateralInActiveAuctions

Why: tokens must be accounted for across both contracts that hold collateral (the Engine for active vaults, the Liquidator for collateral being auctioned). No tokens created or destroyed outside of expected flows. If this breaks, collateral is leaking. Note: if your design keeps all collateral in the Engine (even during auctions), simplify to just balanceOf(engine).

Handler design:

contract SystemHandler is Test {
    // Bounded operations — each wraps protocol calls with realistic inputs
    function depositCollateral(uint256 seed, uint256 amount) external;
    function withdrawCollateral(uint256 seed, uint256 amount) external;
    function mintStablecoin(uint256 seed, uint256 amount) external;
    function repayStablecoin(uint256 seed, uint256 amount) external;
    function moveOraclePrice(uint256 seed, int256 deltaBps) external;  // bounded: ±20%
    function advanceTime(uint256 seconds_) external;                    // bounded: 1-86400
    function liquidate(uint256 seed) external;                          // picks a random vault
    function buyCollateral(uint256 seed, uint256 amount) external;
}

🔗 Connection: This is the same handler + ghost variable + invariant assertion pattern from M8’s VaultInvariantTest exercise. Same methodology, bigger system.

💡 Concept: Fuzz and Fork Testing

Fuzz tests: Beyond invariants, write targeted fuzz tests:

  • Random deposit/mint sequences should never create a vault with HF < 1.0
  • repay(amount) → withdraw(max) should always succeed if there’s no other debt
  • Random price movements followed by health checks should match manual calculation
  • Flash mint with random amounts should always leave totalSupply unchanged after the tx

Fork tests: Deploy on a mainnet fork:

  • Use real Chainlink ETH/USD feed — verify staleness checks work with actual feed behavior
  • Use a real ERC-4626 vault (e.g., Yearn’s yvWETH or a WETH vault) as collateral
  • Measure gas for key operations: deposit, mint, liquidation check, auction bid. Rough ballpark targets (will vary with your implementation choices — storage layout, number of SLOADs, decimal normalization path): deposit/withdraw ~50-80K, mint/repay ~80-120K (includes drip), health factor view ~30-50K, auction bid ~100-150K
  • Compare gas to production protocols (MakerDAO’s frob is ~150-200K gas) — your numbers will differ but should be in the same order of magnitude

⚠️ Edge Cases to Explore

Cascading liquidation: Set up 3 vaults with tight health factors. Drop the price. Liquidate the first — does the Dutch auction’s collateral sale affect the oracle price? (It shouldn’t — Chainlink is off-chain. But if you added an on-chain oracle component, it could.)

Stale oracle + liquidation: What happens if a liquidator calls liquidate() but the Chainlink feed is stale (> heartbeat)? Your PriceFeed should revert, blocking the liquidation. This protects users from being liquidated on stale prices.

Vault share exchange rate drop: The underlying vault suffers a loss (hack, slashing event). Exchange rate drops suddenly. Many vault-share-backed CDPs become liquidatable simultaneously. Does your system handle a flood of auctions?

Flash mint + self-liquidation race: Can a user flash-mint stablecoin, repay their own debt to avoid liquidation, withdraw collateral, and repay the flash mint — all while a liquidation auction is already in progress? Think through the state transitions.

Dust amounts: What happens with 1 wei of collateral or 1 wei of debt? Rounding in the health factor calculation could allow dust vaults that are technically unhealthy but too small to profitably liquidate.

💼 Portfolio & Interview Positioning

What This Project Proves:

  • You can design a multi-contract DeFi protocol from scratch — not fill in TODOs, but make architectural decisions
  • You understand CDP mechanics deeply — normalized debt, rate accumulators, health factors, liquidation
  • You can handle complex pricing challenges — multi-decimal normalization, vault share pricing with manipulation defense
  • You chose Dutch auction over fixed-discount and can explain why (MEV resistance, capital efficiency)
  • You chose immutable design and can articulate the trade-offs vs governed protocols
  • You can write production-quality invariant tests that prove system correctness

Interview Questions This Prepares For:

1. “Walk me through building a CDP-based stablecoin from scratch.”

  • Good: Describe the 4 contracts and their responsibilities.
  • Great: Explain the design decisions — why immutable, why Dutch auction, why rate cap for vault share pricing. Show you understand the trade-off space, not just the implementation.

2. “How would you handle ERC-4626 vault shares as collateral?”

  • Good: Two-step pricing — convertToAssets() then Chainlink for the underlying.
  • Great: Identify the manipulation risk (donation attack), describe the rate cap defense, and explain why you chose it over TWAP or mandatory redemption.

3. “What’s the difference between a flash loan and a flash mint?”

  • Good: Flash loan borrows existing tokens, flash mint creates new ones.
  • Great: Explain why flash mint provides infinite liquidity (no pool constraint), how it enables peg arbitrage without a PSM, and the security implications (totalSupply inflation during callback).

4. “How do you prevent oracle manipulation in a CDP protocol?”

  • Good: Chainlink with staleness checks.
  • Great: Distinguish ETH pricing (straightforward) from vault share pricing (manipulable exchange rate), explain the rate cap mechanism, and note that Chainlink itself is the residual trust assumption in an otherwise decentralized system.

5. “What invariants would you test for a stablecoin protocol?”

  • Good: “Total supply should equal total debt.”
  • Great: List all 5 invariants, explain what each prevents, and describe the handler with 8 bounded operations that stress-tests them.

6. “Why Dutch auction over other liquidation models?”

  • Good: “Less MEV, better price discovery.”
  • Great: Explain two failure modes — English auctions (MakerDAO Liq 1.0) failed on Black Thursday because network congestion prevented keeper bidding. Fixed-discount liquidation (Aave/Compound model) creates gas wars where all liquidators see the same profit → pure priority fee competition → MEV extraction. Dutch auctions solve both: they’re non-interactive (no bidding rounds to miss) and provide natural price discovery — each bidder enters at their own threshold.

Interview Red Flags — things that signal “tutorial-level understanding” in a stablecoin interview:

  • Suggesting fixed-discount liquidation without understanding the MEV problem it creates
  • Not knowing the difference between algorithmic (UST) and collateral-backed (DAI) stablecoins
  • Treating all collateral types as having the same pricing path (ignoring vault share exchange rate complexity)
  • Saying “totalSupply() tells you the total stablecoin debt” — it doesn’t during flash mint callbacks
  • Not being able to explain why drip() must be called before health factor checks

Pro tip: In interviews, describe your protocol by its trade-off position first: “I chose immutability over adaptability, similar to Liquity V1, because…” This signals protocol design thinking, not just Solidity implementation skills. Teams want to hear you reason about the design space before diving into code details.

Pro tip: If asked about stablecoin peg mechanisms, compare at least three approaches (PSM, redemptions, flash mint arbitrage). Showing you understand the design space — not just one solution — is what separates senior candidates from mid-level ones.

How to Present This:

  • Push to a public GitHub repo with a clear README
  • Include an architecture diagram (the ASCII diagram from this doc, or a nicer one)
  • Include a comparison table: your protocol vs MakerDAO vs Liquity (what’s similar, what’s different, why)
  • Include gas benchmarks for core operations (deposit, mint, liquidation, auction bid)
  • Show your invariant test results — this signals maturity beyond basic unit testing
  • Write a brief Architecture Decision Record: the 6 design decisions and your rationale

📋 Key Takeaways: Testing & Hardening

After this section, you should be able to:

  • List all 5 critical invariants (solvency, backing, accounting, health, conservation) and explain what failure of each would mean for the protocol
  • Design an invariant test handler with 8 bounded operations that explores realistic action sequences across multiple actors with random price movements
  • Write fork tests against real Chainlink feeds and real ERC-4626 vaults to verify behavior under production conditions
  • Test edge cases that break naive implementations: cascading liquidations, stale oracles, sudden exchange rate drops, dust positions
Check your understanding
  • Five critical invariants: Solvency (total collateral value >= total debt - bad debt), backing (every stablecoin in circulation is backed by vault collateral), accounting (sum of all individual vault debts equals global total debt), health (every non-liquidated vault has health factor >= 1), conservation (stablecoins are only created/destroyed through mint/burn, never from thin air).
  • Handler design with 8 operations: depositCollateral, withdrawCollateral, mintStablecoin, burnStablecoin, liquidate, updateOraclePrice, accrueInterest, and flashMint — each with bounded inputs and multi-actor selection. Random price movements via the oracle handler stress-test liquidation cascades and health factor boundaries.
  • Fork testing: Deploy your contracts on a mainnet fork with real Chainlink feeds and real ERC-4626 vaults (e.g., Yearn or Aave aToken vaults). Verify that your PriceFeed correctly handles real oracle responses, decimals, and staleness, and that vault share pricing matches actual on-chain exchange rates.
  • Edge cases: Cascading liquidations (one liquidation triggers others by moving the market), stale oracles (Chainlink feed not updated for hours), sudden exchange rate drops (vault hack reduces share value instantly), and dust positions (tiny vaults where gas cost exceeds liquidation incentive, leaving bad debt).

🧭 Checkpoint — Before Starting to Build: Can you list all 5 invariants from memory and explain what failure of each one would mean for the protocol? Can you describe at least 4 handler operations and how they interact with the invariants? If yes, you understand the system well enough to build it. If not, re-read the invariants — they are the specification you’re implementing against.


📖 Suggested Build Order

This is guidance, not prescription. Adapt to your working style — but if you’re not sure where to start, this sequence builds from simple to complex with testable milestones at each phase.

Phase 1: The token (~half day)

Build Stablecoin.sol first. It’s the simplest contract — an ERC-20 with authorized mint/burn and flash mint. You can unit test it in isolation before any other contract exists. Getting ERC-3156 working early means you understand the flash mint callback pattern before wiring it into the system.

Checkpoint: Deploy Stablecoin in a test, flash mint 1M tokens, verify totalSupply is unchanged after the tx.

Phase 2: The oracle (~half day)

Build PriceFeed.sol next. Two pricing paths: ETH via Chainlink, vault shares via convertToAssets() + Chainlink. Test with mock Chainlink feeds. Implement the rate cap for vault share pricing. This is standalone — no dependencies on other protocol contracts.

Checkpoint: Mock Chainlink returns $3,000. Mock vault returns 1.05 rate. Verify PriceFeed returns correct USD values for both collateral types. Simulate a donation attack — verify rate cap catches it.

Phase 3: The engine (~2-3 days)

Build StablecoinEngine.sol — the core. This is the bulk of the work. Start with the simplest flow (deposit ETH + mint) and build outward: repay, withdraw, drip, health factor. Add vault share collateral support after ETH works end-to-end. Leave seizeCollateral() as a stub initially.

Checkpoint: Full vault lifecycle with ETH: deposit → mint → warp time → drip → repay → withdraw. Health factor correct. Debt ceiling enforced. Then repeat with vault share collateral.

Phase 4: The liquidator (~1-2 days)

Build DutchAuctionLiquidator.sol. Wire it to the Engine’s seizeCollateral(). Start with linear decay (you already built this pattern in M6’s SimpleDog), then optionally upgrade to exponential step.

Checkpoint: Create a vault, drop the oracle price, verify liquidation triggers, verify auction price decays, verify bidder receives collateral and stablecoin is burned. Test partial fills. Test bad debt path.

Phase 5: Integration testing (~1-2 days)

Wire everything together. Write the 5 invariant tests with the system handler. Run fuzz tests. Fork test with real Chainlink. Explore edge cases. Profile gas. Write your Architecture Decision Record.

Checkpoint: All 5 invariants pass with depth ≥ 50, runs ≥ 256. Fork test works. Gas benchmarks logged.


📖 How to Study MakerDAO’s dss

MakerDAO’s codebase uses terse, domain-specific naming that can be disorienting. This decoder table maps their names to your protocol’s cleaner equivalents:

MakerDAO (dss)Your ProtocolWhat It Is
vatStablecoinEngineCore CDP accounting
inkvault.collateralAmountCollateral in a vault
artvault.normalizedDebtNormalized debt (actual = art × rate)
rateconfig.rateAccumulatorPer-type rate accumulator
spotPriceFeed valueCollateral price × liquidation ratio
jugdrip() logicStability fee accrual
dogDutchAuctionLiquidatorLiquidation trigger
clipAuction logicDutch auction execution
barkliquidate()Start a liquidation
takebuyCollateral()Bid on an auction
frobdeposit() + mint()Modify vault (collateral and/or debt)
grabseizeCollateral()Forceful vault seizure for liquidation
sintotalBadDebtUnbacked system debt
daiStablecoinThe stablecoin token

Reading order for MakerDAO dss:

  1. Start with testsvat.t.sol shows how frob and grab are used in practice
  2. Map to your protocol — mentally replace ink/art/rate with your names as you read
  3. Read jug.sol next — it’s short (~80 lines) and maps directly to your drip()
  4. Read dog.sol + clip.sol — your Liquidator mirrors this pair
  5. Skip spot.sol initially — it handles oracle integration differently than your PriceFeed
  6. Skip NatSpec docs initially/// comments describe function behavior but add reading noise when you’re tracing logic. Certora formal verification specs (separate .spec files) can also be ignored for now

Don’t get stuck on: MakerDAO’s auth modifier pattern, the wards mapping, or the rely/deny authorization system. These are MakerDAO-specific access control — your protocol uses simpler immutable authorization.


✅ Self-Assessment Checklist

Architecture

  • 4-contract structure designed and implemented (Engine, PriceFeed, Liquidator, Stablecoin)
  • Clear separation of concerns — Engine doesn’t know about auction mechanics, Liquidator doesn’t know about rate accumulators
  • Design decisions documented with rationale

Core Engine

  • Vault lifecycle works end-to-end: deposit → mint → repay → withdraw
  • Health factor correct for ETH collateral (single Chainlink lookup)
  • Health factor correct for vault share collateral (two-step pricing)
  • drip() called before every debt-reading operation
  • Rate accumulator compounds correctly over time (test with multi-day time warps)
  • Debt ceiling enforced per collateral type
  • Decimal normalization correct across all token types

Pricing

  • PriceFeed handles ETH pricing via Chainlink with staleness check
  • PriceFeed handles vault share pricing with convertToAssets() + underlying price
  • Rate cap protects against vault share exchange rate manipulation
  • Price returns consistent decimal base for both collateral types

Liquidation

  • Dutch auction starts at correct price (oracle × buffer)
  • Price decreases over time according to decay function
  • Partial fills work correctly (bidder buys portion, auction continues)
  • Surplus collateral refunded to vault owner when tab is fully covered
  • Bad debt tracked when auction doesn’t fully recover

Flash Mint

  • ERC-3156 interface implemented on Stablecoin
  • maxFlashLoan() returns type(uint256).max
  • Mint → callback → burn works atomically
  • Reentrancy guard on flashLoan()
  • Fee handling correct (if nonzero fee chosen)

Testing

  • Unit tests for every function and error path
  • Fuzz tests with random amounts, prices, and operation sequences
  • All 5 critical invariants implemented and passing (depth ≥ 50, runs ≥ 256)
  • Fork test with real Chainlink oracle and real ERC-4626 vault
  • Gas benchmarks for core operations logged

Stretch Goals

  • Exponential step decay function (instead of linear)
  • Protocol surplus buffer funded by stability fee revenue
  • Multiple collateral types per vault (not just one type per vault per user)
  • Dust threshold enforcement (minimum vault size)
  • Architecture Decision Record written for portfolio
  • Foundry deployment script showing correct 4-contract wiring order

This completes Part 2: DeFi Foundations. You’ve gone from individual primitives (tokens, AMMs, oracles, lending, flash loans, CDPs, vaults, security) to designing and building a complete protocol. The stablecoin you built integrates every concept from Modules 1-8 into a cohesive, decentralized system. Next: Part 3 — Modern DeFi Stack.


Navigation: ← Module 8: DeFi Security | Part 3: Advanced DeFi Patterns →