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:
| Contract | Role |
|---|---|
| DaiEngine.sol | The core CDP engine. Handles all user interactions and enforces all rules. |
| DAIToken.sol | A standard ERC-20 stablecoin. Only the engine can mint or burn it. |
| Oracle.sol | Reports the USD price of the collateral token, scaled by 1e18. |
| SwapContract.sol | Absorbs bad debt when a liquidation cannot fully cover outstanding DAI. |
Every user position is stored in a single Position struct inside the engine:
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:
| Parameter | Value | Meaning |
|---|---|---|
| threshold | 0.7e18 | Max DAI mintable = 70% of collateral value |
| rateAcc | 1e18 (starts) | Global borrow index, always increasing as interest accrues |
| rateFee | 1000000001547125957 | Per-second multiplier ≈ 5% APY compound interest |
| liquidationBonus | 0.05e18 | 5% extra collateral given to liquidators as an incentive |
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.
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:
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.
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.
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:
- Collateral value in USD — multiply the raw collateral token amount by
the oracle price and divide by the oracle's scale factor (
1e18). - 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.
- Current debt in DAI — convert existing debt shares to DAI at the
current
rateAcc. This already includes accrued interest, because we calledaccrueInterest()first.
- 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.
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.
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.
- 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.
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:
| Mode | You provide | Engine computes | Use when |
|---|---|---|---|
| Mode A | debt shares | collateral to seize | You know how much DAI you want to burn |
| Mode B | seizedCollateral | debt covered | You know how much collateral you want |
- 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 DAIof 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:
Deposit
Lock collateral. No health check needed. Collateral balance increases.
Borrow
Mint DAI ≤ 70% of collateral value. Debt recorded as shares at current rateAcc.
Interest accrues
rateAcc grows each second. Debt in DAI terms quietly increases even with no action.
Repay
Burn DAI (principal + interest). Debt shares decrease.
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.