How a Collateralized Stablecoin Works: A Code Walkthrough

A simplified walkthrough of MakerDAO's collateralized stablecoin system — so you can understand how algorithmic DAI actually works under the hood.

What you will learn

System Overview

How to mint stablecoin through the process of depositing collateral

Interest Model

Borrowers who mint DAI pay a continuous stability fee — the interest that accrues on their debt — which keeps the system solvent and the peg intact

Core Operations

The core operations that happens on the system

Liquidations & Bad Debt

The critical operations that recover the DAI price in case of depeg

Introduction

Most stablecoins today — USDC, USDT — are issued and controlled by centralized operators. DAI, built by MakerDAO, takes a different approach: it is decentralized, backed by on-chain collateral, and governed by code rather than a corporation.

The core idea is straightforward — lock up crypto, mint DAI pegged to $1, and the protocol enforces solvency through over-collateralization and open liquidations. But the production codebase is anything but simple: it spans dozens of contracts, annoyed naming, has assembly-level math, and years of governance complexity layered on top.

To cut through that, I wrote dai-simple, a single-contract re-implementation with the same core mechanics and none of the noise. No governance, no multi-collateral support — just the essential logic of a CDP (Collateralized Debt Position) system.

This article walks through each operation in order: deposit → borrow → repay → withdraw, and finally liquidation — what happens when a position becomes undercollateralized.

System Overview

The system is made up of four contracts that each play a distinct role:

ContractRole
DaiEngine.solThe core CDP engine. Handles all user interactions and enforces all rules.
DAIToken.solA standard ERC-20 stablecoin. Only the engine can mint or burn it.
Oracle.solReports the USD price of the collateral token, scaled by 1e18.
SwapContract.solAbsorbs bad debt when a liquidation cannot fully cover outstanding DAI.

Every user position is stored in a single Position struct inside the engine:

DaiEngine.sol Javascript
struct Position {
    uint256 collateral;  // amount of collateral token locked
    uint256 debt;        // debt *shares* (not actual DAI)
}

mapping(address user => Position position) positions;

Notice that debt is stored as shares, not as raw DAI. We will see why in the next section. The key parameters set at deployment are:

ParameterValueMeaning
threshold0.7e18Max DAI mintable = 70% of collateral value
rateAcc1e18 (starts)Global borrow index, always increasing as interest accrues
rateFee1000000001547125957Per-second multiplier ≈ 5% APY compound interest
liquidationBonus0.05e185% extra collateral given to liquidators as an incentive
DaiEngine.sol — constructor Javascript
constructor(address initAsset, address initOracle, address initDAI, address initSwap, uint256 initThreshold) {
    ASSET     = IERC20(initAsset);
    DAI       = IDAI(initDAI);
    oracle    = IOracle(initOracle);
    swapContract = ISwap(initSwap);

    threshold  = initThreshold;          // e.g. 0.7e18  → 70 % LTV
    rateAcc    = MathLib.WAD;            // starts at 1e18
    rateFee    = 1000000001547125957;    // ~5 % APY per-second multiplier
    updatedAt  = block.timestamp;
    liquidationBonus = 0.05e18;          // 5 %
}

The Interest Model: Debt Shares and rateAcc

Before we look at the individual operations, it is important to understand how interest works — because every state-changing function calls accrueInterest() as its very first step.

Why store debt shares instead of DAI?

If we stored each borrower's debt as a raw DAI amount, we would need to iterate over every position every second to add interest — which is gas-prohibitive on Ethereum.

Instead Compound use the same technique as Compound: a global borrow index called rateAcc. When you borrow, your debt is stored as shares of the current index. When you repay (or get liquidated), those shares are converted back to DAI at the current index, which has grown over time. The interest is captured automatically, with no loops.

Concrete Example

Suppose you borrow when rateAcc = 1e18. You want 1000 DAI, so the engine records debt = 1000 shares.

One year later, interest has grown rateAcc to 1.05e18 (~5% APY). Now:

debtToDai(1000) = 1000 × 1.05e18 / 1e18 = 1050 DAI

You owe 50 DAI more than you borrowed — the stability fee, collected automatically.

How rateAcc grows

accrueInterest() uses compound exponentiation (rpow) to grow the index by rateFee^elapsed_seconds:

DaiEngine.sol — accrueInterest & debtToDai Javascript
function accrueInterest() public {
    if (block.timestamp == updatedAt) return;   // nothing to do

    uint256 elapsed = block.timestamp - updatedAt;
    // rateAcc grows each second: rateAcc = rateAcc * rateFee^elapsed
    rateAcc   = MathLib.mulWadDown(MathLib.rpow(rateFee, elapsed, MathLib.WAD), rateAcc);
    updatedAt = block.timestamp;
}

// Debt shares → actual DAI owed
function debtToDai(uint256 debt) internal view returns (uint256) {
    uint256 amount = MathLib.mulWadDown(debt, rateAcc);  // debt * rateAcc
    return amount * DAI_BASE / MathLib.WAD;
}

Because rateAcc is a shared global, all existing borrowers pay the same rate. No per-user update is needed. And since accrueInterest() is called at the start of every mutating function, the index is always up-to-date before any balance changes.

1. Deposit — Locking Collateral

Everything starts with depositing collateral. This is the simplest operation: no interest accrual is needed, no health check is required. The engine just pulls the tokens in and records them.

DaiEngine.sol — deposit Javascript
function deposit(uint256 amount) external {
    require(amount > 0, ErrorsLib.ZeroAmount());

    // Pull collateral from caller into the engine
    ASSET.safeTransferFrom(msg.sender, address(this), amount);
    positions[msg.sender].collateral += amount;
}

After this call, positions[user].collateral increases. The user now has "buying power" to mint DAI.

2. Borrow — Minting DAI

With collateral locked, the user can mint DAI. The engine computes the maximum DAI allowed, checks the new debt does not exceed it, and mints.

DaiEngine.sol — borrow Javascript
function borrow(uint256 debt) external {
    accrueInterest();                       // 1. update interest first
    require(debt > 0, ErrorsLib.ZeroAmount());

    uint256 daiAmount = debtToDai(debt);    // 2. convert shares → DAI

    // 3. check collateral is sufficient
    uint256 collateralValue =
        MathLib.mulDivDown(positions[msg.sender].collateral, oracle.price(), oracle.SCALE_FACTOR());
    uint256 maxDai     = MathLib.mulWadDown(collateralValue, threshold);
    uint256 currentDebt = debtToDai(positions[msg.sender].debt);
    require(currentDebt + daiAmount <= maxDai, ErrorsLib.ExceedThreshold());

    // 4. record debt as shares and mint DAI to caller
    positions[msg.sender].debt += debt;
    DAI.mint(msg.sender, daiAmount);
}

The collateral check uses three values in sequence, which is worth spelling out clearly:

  1. Collateral value in USD — multiply the raw collateral token amount by the oracle price and divide by the oracle's scale factor (1e18).
  2. Maximum DAI — multiply that USD value by the 70% threshold. So if your collateral is worth $10,000, you can borrow at most $7,000 of DAI.
  3. Current debt in DAI — convert existing debt shares to DAI at the current rateAcc. This already includes accrued interest, because we called accrueInterest() first.
Example: First Borrow
  • Collateral: 5 tokens, oracle price: $2,000
  • Collateral value: 5 × 2000 = $10,000
  • Max DAI (70%): $7,000 DAI
  • Existing debt: 0
  • You may borrow up to: 7,000 DAI

The debt parameter is passed as shares, not as a DAI amount. In practice, when rateAcc = 1e18 (fresh system), shares and DAI are equal. As interest accrues and rateAcc grows above 1e18, you need fewer shares to represent the same DAI.

3. Repay — Paying Back DAI

Repaying is the mirror image of borrowing. The user burns DAI, and the engine reduces their debt shares. The key insight is that repaying more DAI than you originally borrowed is entirely expected — the extra covers the accrued interest.

DaiEngine.sol — repay Javascript
function repay(uint256 debt) external {
    accrueInterest();                       // 1. update interest first
    require(debt > 0, ErrorsLib.ZeroAmount());

    uint256 daiAmount = debtToDai(debt);    // 2. convert shares → DAI (includes accrued interest)

    // 3. reduce debt and burn the DAI from caller
    positions[msg.sender].debt -= debt;
    DAI.burn(msg.sender, daiAmount);
}

Because accrueInterest() runs first, the conversion debtToDai(debt) returns the correct amount including all interest up to this block. If you borrowed 1000 DAI a year ago, you might burn 1050 DAI to clear the position.

This is also how the stability fee helps maintain the peg: borrowers who need extra DAI to cover interest must buy it on the open market, creating consistent demand that supports the $1 price.

Partial repayment is fine

You can pass any number of debt shares less than your total. The position stays open with the remaining shares. There is no minimum repayment amount beyond zero.

4. Withdraw — Retrieving Collateral

Once you have repaid enough DAI, you can withdraw your collateral. Unlike deposit, this operation can violate the health ratio, so the engine performs a check after tentatively removing the collateral.

DaiEngine.sol — withdraw Javascript
function withdraw(uint256 amount) external {
    accrueInterest();                       // 1. update interest first
    require(amount > 0, ErrorsLib.ZeroAmount());

    // 2. tentatively remove collateral
    positions[msg.sender].collateral -= amount;

    // 3. verify the remaining collateral still covers existing debt
    uint256 collateralValue =
        MathLib.mulDivDown(positions[msg.sender].collateral, oracle.price(), oracle.SCALE_FACTOR());
    uint256 maxDai      = MathLib.mulWadDown(collateralValue, threshold);
    uint256 currentDebt = debtToDai(positions[msg.sender].debt);
    require(currentDebt <= maxDai, ErrorsLib.ExceedThreshold());

    // 4. transfer collateral back to caller
    ASSET.safeTransfer(msg.sender, amount);
}

Notice the order: the engine subtracts the collateral first, then checks whether the remaining position is still healthy. This optimistic update pattern avoids keeping a local copy of the pre-update value. If the check fails, the transaction reverts and the subtraction is rolled back — no state change persists.

Example: Partial Withdrawal
  • Current collateral: 5 tokens @ $2,000 = $10,000
  • Current debt: 3,000 DAI
  • You try to withdraw 2 tokens ($4,000)
  • Remaining collateral value: $6,000 → max DAI at 70% = $4,200
  • 3,000 ≤ 4,200 → withdrawal succeeds ✓

Try withdrawing 3 tokens instead:

Remaining collateral value: $4,000 → max DAI = $2,800

3,000 > 2,800 → ExceedThreshold revert ✗

5. Liquidate — Closing Unhealthy Positions

A position becomes unhealthy when the debt (including accrued interest) grows beyond threshold (70% in our example) of the collateral's USD value — either because interest piled up, or because the collateral price dropped. At that point, anyone can liquidate it.

The liquidator burns DAI to cover the debt and receives the borrower's collateral at a 5% discount. This discount is the liquidator's profit motive and ensures positions are closed quickly, protecting the DAI peg.

Without liquidation, unhealthy positions would linger and worsen. Eventually, the collateral backing a position would no longer cover the DAI minted against it — leaving unbacked DAI in circulation. That unbacked supply breaks the peg.

DaiEngine.sol — liquidate Javascript
function liquidate(address borrower, uint256 debt, uint256 seizedCollateral) external {
    accrueInterest();

    // Exactly one of debt / seizedCollateral must be non-zero
    require((debt == 0) != (seizedCollateral == 0), ErrorsLib.InvalidInput());

    // 1. confirm position is unhealthy
    uint256 collateralValue =
        MathLib.mulDivDown(positions[borrower].collateral, oracle.price(), oracle.SCALE_FACTOR());
    uint256 maxDai      = MathLib.mulWadDown(collateralValue, threshold);
    uint256 currentDebt = debtToDai(positions[borrower].debt);
    require(currentDebt > maxDai, ErrorsLib.PositionHealthy());

    uint256 daiAmount;
    uint256 debtShares;

    if (debt > 0) {
        // Mode A: caller specifies debt shares to cover → engine computes collateral
        debtShares = debt;
        daiAmount  = debtToDai(debt);
        uint256 computed = MathLib.mulDivDown(
            MathLib.mulWadDown(daiAmount, MathLib.WAD + liquidationBonus),
            oracle.SCALE_FACTOR(), oracle.price()
        );
        require(computed <= positions[borrower].collateral, ErrorsLib.ExceedCollateral());
        seizedCollateral = computed;
    } else {
        // Mode B: caller specifies collateral to seize → engine computes debt
        require(seizedCollateral <= positions[borrower].collateral, ErrorsLib.ExceedCollateral());
        daiAmount = MathLib.mulDivDown(
            MathLib.mulDivDown(seizedCollateral, oracle.price(), oracle.SCALE_FACTOR()),
            MathLib.WAD, MathLib.WAD + liquidationBonus
        );
        debtShares = MathLib.mulDivDown(
            MathLib.mulDivDown(daiAmount, MathLib.WAD, rateAcc),
            MathLib.WAD, DAI_BASE
        );
    }

    // 2. update position
    positions[borrower].debt       -= debtShares;
    positions[borrower].collateral -= seizedCollateral;

    // 3. liquidator pays DAI and receives collateral + 5 % bonus
    DAI.burn(msg.sender, daiAmount);
    ASSET.safeTransfer(msg.sender, seizedCollateral);

    // 4. bad-debt path: all collateral gone but debt still remains
    if (positions[borrower].collateral == 0 && positions[borrower].debt > 0) {
        uint256 badDebt = debtToDai(positions[borrower].debt);
        positions[borrower].debt = 0;
        swapContract.swap(badDebt);   // socialize the loss
    }
}

Two Liquidation Modes

The function accepts two exclusive input modes, giving liquidators flexibility:

ModeYou provideEngine computesUse when
Mode Adebt sharescollateral to seizeYou know how much DAI you want to burn
Mode BseizedCollateraldebt coveredYou know how much collateral you want
Example: Liquidation Math (Mode A)
  • Collateral: 1 token @ $1,800 = $1,800 (price dropped from $2,000)
  • Outstanding debt: 1,500 DAI
  • Max DAI at 70%: $1,260 → unhealthy
  • Liquidator covers 500 DAI of debt:
  • Collateral seized = 500 × (1 + 0.05) / 1800 = 0.2917 collateral tokens
  • Liquidator profit: 0.2917 × $1,800 = $525 — paid $500 DAI, received $525 in collateral → $25 profit (5%)

Bad Debt: When Collateral Runs Out

In a severe price crash, a position's collateral value may fall below the outstanding debt before anyone liquidates it. In that case, seizing all the collateral still leaves DAI in circulation without backing — this is called bad debt.

The engine handles this at the end of liquidate(): if all collateral is seized and debt still remains, the leftover debt is forwarded to SwapContract. In a production system this contract would mint a governance token, auction it, and use the proceeds to buy and burn the uncovered DAI — restoring the peg. In this simplified implementation it just records the total bad debt for observability.

Why can bad debt occur?

Liquidations are only profitable when there is a collateral surplus to capture. If the price drops faster than liquidators can act — for example during a flash crash — the collateral value may already be below the debt value by the time the transaction lands. The 5% liquidation bonus also means liquidators will not act until the debt exceeds the collateral by at least 5%, leaving a window where bad debt can accumulate.

Putting It All Together

Here is the lifecycle of a typical position from start to finish:

1

Deposit

Lock collateral. No health check needed. Collateral balance increases.

2

Borrow

Mint DAI ≤ 70% of collateral value. Debt recorded as shares at current rateAcc.

3

Interest accrues

rateAcc grows each second. Debt in DAI terms quietly increases even with no action.

4

Repay

Burn DAI (principal + interest). Debt shares decrease.

5

Withdraw

Retrieve collateral. Remaining collateral must still cover remaining debt.

Liquidate (if unhealthy)

Third party burns DAI, seizes collateral + 5% bonus. Bad debt forwarded to SwapContract.

Conclusion

A collateralized stablecoin system is, at its core, a set of rules about when you can mint money and when you lose the right to your collateral. The five operations we covered — deposit, borrow, repay, withdraw, and liquidate — are all implementations of those rules, with compound interest woven throughout via the debt-share / borrow-index pattern.

The real MakerDAO adds a lot on top: multiple collateral types, a savings rate for DAI holders, on-chain governance, liquidation auctions, circuit breakers, and more. But every one of those features is built on the same foundation you just read through. Once you understand how a position opens, stays healthy, and gets closed, the rest is detail.

If you want to experiment, clone dai-simple and run forge test -vvv. The test files cover each scenario with concrete numbers — a great way to build intuition quickly.