适合谁读:刚开始学区块链开发、会一点 Solidity 的同学。
MakerDAO 是以太坊上最早的借贷协议之一,稳定币 DAI 就是它发行的。它的合约代码出了名的难读:变量名只有三四个字母,一个功能拆在好几个合约里。
本文换一个读法:把 MakerDAO 想象成一家开在链上的当铺,跟着“押东西、借钱、还钱、押品被拍卖”这条线,一步一步看每个合约在当铺里扮演什么角色、代码怎么写。
文中所有合约都是以太坊主网上正在运行的真实合约,附有地址,源码可以在 Etherscan 上查看。文中的“实测”数据,是 2026-10-28 在主网分叉(把主网状态复制到本地的测试环境,区块 26288434)上实际调用得到的。
目录
- 一、DAI 是什么
- 二、把 MakerDAO 想象成一家当铺
- 三、第 1 步:把东西押进当铺(GemJoin、Vat)
- 四、第 2 步:借钱(Spotter、Vat、DaiJoin、Dai)
- 五、第 3 步:计息和还钱(Jug)
- 六、第 4 步:押品不值钱了,拍卖(Dog、Clipper、Abacus)
- 七、Dai.sol:当铺发的钞票
- 八、当铺里的其他角色
- 九、动手:自己借一笔 DAI
- 十、新手容易踩的坑
- 十一、DAI 和 USDT 有什么不同
一、DAI 是什么
DAI 是以太坊上最早、最有代表性的去中心化稳定币之一,目标价格是 1 DAI ≈ 1 美元。
同样是稳定币,USDT 和 DAI 的来历完全不同:
- USDT 像商场的储值卡。你把 100 美元交给 Tether 公司,公司给你 100 USDT。这张卡值不值钱,取决于公司是不是真的把钱存着。
- DAI 像当铺借出来的钱。你把一块金表押进当铺,当铺按估价借给你一笔钱。不同的是,这家当铺没有老板,所有规则都写在智能合约里:能借多少、利息多少、什么时候拍卖押品,全部公开,任何人都能查。
所以 DAI 的价值来自链上看得见的抵押资产,而不是某家公司的承诺。1 DAI 和 1 美元之间也不是固定兑换,而是靠超额抵押、清算、利率调节等机制把价格稳定在 1 美元附近(软锚定),偶尔会有小幅偏离。
补充:2024 年 MakerDAO 更名为 Sky,推出了新稳定币 USDS,DAI 仍在流通。本文讲的合约至今仍在主网运行,实测时 ETH-A 金库可以正常抵押、借款和清算。
二、把 MakerDAO 想象成一家当铺
2.1 当铺里的角色
在当铺借钱,会遇到这些人和东西:收押品的柜台、估价师、账本、付现金的柜台、算利息的账房、发现押品贬值的巡查员、拍卖行……MakerDAO 的每个合约,正好对应其中一个角色:
| 当铺里的角色 | 合约 | 以太坊主网地址 | 它做什么 |
|---|---|---|---|
| 📒 总账本 | Vat | 0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B | 记录每个人押了多少东西、欠了多少钱。所有合约都围着它转 |
| 📦 押品柜台 | GemJoin(ETH-A) | 0x2F0b23f53734252Bda2277357e97e1517d6B042A | 收下你的 WETH,在账本上记一笔;还清后把东西退给你 |
| 🔍 估价师 | Spotter | 0x65C79fcB50Ca1594B025960e539eD7A9a6D434A3 | 根据市价打个折,算出“这件东西最多能借多少钱” |
| 💵 现金柜台 | DaiJoin | 0x9759A6Ac90977b93B58547b4A71c78317f391A28 | 把账本上的钱换成现金(DAI),或把现金收回来 |
| 💴 钞票 | Dai | 0x6B175474E89094C44Da98b954EedeAC495271d0F | 钱包里看到的 DAI,一个 ERC20 代币 |
| 🧮 账房先生 | Jug | 0x19c0976f590D67707E62397C87829d896Dc0f1F1 | 按秒算利息 |
| 🚨 巡查员 | Dog | 0x135954d155898D42C90D2a57824C690e0c7BEf1B | 发现押品价值不够还债了,就把它没收,送去拍卖 |
| 🔨 拍卖行 | Clipper(ETH-A) | 0xc67963a226eddd77B91aD8c421630A1b0AdFF270 | 拍卖被没收的押品 |
| 🏷️ 降价牌 | Abacus(ETH-A) | 0x7d9f92DAa9254Bbd1f479DBE5058f74C2381A898 | 决定拍卖价格怎么随时间往下降 |
每种抵押品都有自己的押品柜台和拍卖行,比如 ETH-A、WBTC-A 各有一套。本文都以 ETH-A 为例。ETH-A 只收 WETH(ETH 的 ERC20 包装版,地址
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2),所以 ETH 要先换成 WETH。这些地址都可以在 MakerDAO 官方的地址簿合约 Chainlog(
0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F)里查到。在 Etherscan 上打开任意地址,点 Contract 标签就能看到源码。
2.2 借一笔钱的完整流程
第 1 步:押东西
你的 WETH ──押品柜台 GemJoin──▶ 账本 Vat 记下:你押了 10 ETH
第 2 步:借钱
估价师 Spotter 告诉账本:1 ETH 最多借 1618 DAI
账本 Vat 检查后记下:你借了 10000 DAI
现金柜台 DaiJoin ──▶ Dai 合约印出 10000 DAI 给你
第 3 步:还钱
账房 Jug 按秒算利息:一年后要还 11050 DAI
你把 DAI 交回现金柜台 ──▶ Dai 合约销毁这些 DAI
账本 Vat 销账 ──▶ 押品柜台把 10 ETH 退给你
第 4 步:(如果押品不值钱了)拍卖
巡查员 Dog 发现欠款超过了押品的可借额度 ──▶ 没收押品
拍卖行 Clipper 开拍,降价牌 Abacus 让价格每 90 秒降 1%
有人觉得划算就买走,钱用来抵你的债
2.3 先认识几个“黑话”
MakerDAO 的变量名很短,第一次看会很懵。对照当铺来记:
| 名字 | 含义 | 当铺里的说法 |
|---|---|---|
ilk | 抵押品类型,例如 "ETH-A" | 押品种类:金表、玉镯…… |
urn | 一个金库,即某人在某种抵押品下的仓位 | 你的当票 |
gem | 已交到柜台、但还没押上的抵押品 | 放在柜台上、还没签当票的金表 |
ink | 金库里锁定的抵押品数量 | 当票上写的押品 |
art | 标准化债务(不是实际欠款) | 当票上记的“借款份数” |
rate | 每份债务现在值多少钱。实际欠款 = art × rate | 每份借款的“当前价格”,随利息上涨 |
spot | 每单位抵押品最多能借多少 | 估价师给出的“可借额度” |
dai(Vat 里的) | 账本上的 DAI 余额 | 记在账上、还没取出来的钱 |
dink / dart | ink / art 的变化量,可正可负 | 这次多押 / 少押、多借 / 少借 |
为什么要用“份数 × 价格”来记债? 想象一下:当铺有几万张当票,如果每过一秒都要给每张当票加一点利息,账房先生会累死(在链上就是 gas 费高到付不起)。MakerDAO 的办法很聪明:每张当票只记“借了多少份”,利息只体现在一个全局数字 rate(每份值多少钱)上。账房先生每次只改这一个数字,所有人的欠款就一起涨了。这和基金很像:你持有的份额不变,净值涨了,你的资产就涨了。
还有三种数字精度,Vat 里的数字看起来特别长就是这个原因:
| 单位 | 精度 | 用在哪里 |
|---|---|---|
wad | 10^18 | 代币数量,例如 ink、art、钱包里的 DAI |
ray | 10^27 | 比率,例如 rate、spot |
rad | 10^45 | wad × ray 的结果,例如 Vat 里的 dai |
三、第 1 步:把东西押进当铺(GemJoin、Vat)
生活场景:你走进当铺,把金表交给柜台。柜台收下金表放进保险柜,在账本上记一笔“某某交来金表一块”。然后你签当票,金表正式变成押品。
对应到合约:
- 你把 WETH 交给 GemJoin(押品柜台),Vat 的
gem里给你记一笔; - 你调用 Vat.frob,把
gem锁进金库,变成ink。
为什么要分成柜台和账本两个合约? 账本(Vat)只记数字,从不碰真正的代币,真正的 WETH 由柜台(GemJoin)保管。这样当铺想新收一种押品(比如 WBTC),只要新开一个柜台,账本一行代码都不用改。
GemJoin:押品柜台
// GemJoin(ETH-A):0x2F0b23f53734252Bda2277357e97e1517d6B042A
contract GemJoin is LibNote {
VatLike public vat; // 账本
bytes32 public ilk; // 收哪种押品,例如 "ETH-A"
GemLike public gem; // 押品代币,例如 WETH
uint public dec; // 押品代币的小数位
uint public live; // 1 表示营业中,0 表示已关门
// 交押品:把 msg.sender 的 wad 个代币收进来,在账本上给 usr 记一笔
function join(address usr, uint wad) external note {
require(live == 1, "GemJoin/not-live");
require(int(wad) >= 0, "GemJoin/overflow");
vat.slip(ilk, usr, int(wad)); // 账本:gem 增加
require(gem.transferFrom(msg.sender, address(this), wad), "GemJoin/failed-transfer"); // 需要事先 approve
}
// 取押品:账本上扣掉 msg.sender 的 gem,把真正的代币还给 usr
function exit(address usr, uint wad) external note {
require(wad <= 2 ** 255, "GemJoin/overflow");
vat.slip(ilk, msg.sender, -int(wad)); // 账本:gem 减少(不够会失败)
require(gem.transfer(usr, wad), "GemJoin/failed-transfer");
}
}
| 方法 | 在做什么 | 谁能调用 |
|---|---|---|
join(usr, wad) | 交押品,账本上 gem 增加 | 任何人(需先对 GemJoin approve) |
exit(usr, wad) | 取押品,账本上 gem 减少 | 任何人(只能取自己的 gem) |
cage() | 关门,之后不能再交押品 | 管理员 |
rely / deny | 增删管理员 | 管理员 |
Vat:总账本
先看账本上记了什么:
// Vat:0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B
contract Vat {
// 每种押品的规则
struct Ilk {
uint256 Art; // 这种押品的借款份数总和 [wad]
uint256 rate; // 每份借款现在值多少,实际欠款 = 份数 × rate [ray]
uint256 spot; // 每单位押品最多能借多少 DAI [ray]
uint256 line; // 这种押品的借款总上限 [rad]
uint256 dust; // 每张当票的最低借款额 [rad]
}
// 一张当票
struct Urn {
uint256 ink; // 押了多少 [wad]
uint256 art; // 借了多少份 [wad]
}
mapping (bytes32 => Ilk) public ilks; // 押品种类 => 规则
mapping (bytes32 => mapping (address => Urn )) public urns; // 押品种类 => 地址 => 当票
mapping (bytes32 => mapping (address => uint)) public gem; // 交到柜台还没押上的押品 [wad]
mapping (address => uint256) public dai; // 账上的 DAI 余额 [rad]
uint256 public debt; // 当铺借出的 DAI 总额 [rad]
uint256 public Line; // 全当铺的借款总上限 [rad]
uint256 public live; // 1 表示营业中
// 委托书:can[我][别人] = 1,表示允许“别人”动我账上的东西
mapping(address => mapping (address => uint)) public can;
function hope(address usr) external note { can[msg.sender][usr] = 1; } // 签委托书
function nope(address usr) external note { can[msg.sender][usr] = 0; } // 撤销委托书
// 本人,或者本人签过委托书的人
function wish(address bit, address usr) internal view returns (bool) {
return either(bit == usr, can[bit][usr] == 1);
}
// 修改某人柜台上的押品数量,只有柜台(GemJoin)能调用
function slip(bytes32 ilk, address usr, int256 wad) external note auth {
gem[ilk][usr] = add(gem[ilk][usr], wad);
}
(为了好读,这里省略了坏账相关的 sin、vice 等变量,后面清算时再提。)
签当票用的是 frob。它是 Vat 最重要的函数,“押东西 / 取东西 / 借钱 / 还钱”全靠它,下一步详细讲。这一步只用到它的一半:多押东西、不借钱。
实测(账户 U 押入 10 ETH)
| 操作 | 结果 |
|---|---|
WETH.deposit{value: 10 ETH}(),WETH.approve(GemJoin, 最大值) | 拿到 10 WETH 并授权给柜台 |
GemJoin.join(U, 10e18) | 账本:gem = 10,当票 ink = 0 |
Vat.frob("ETH-A", U, U, U, 10e18, 0) | 账本:gem = 0,当票 ink = 10,art = 0 |
四、第 2 步:借钱(Spotter、Vat、DaiJoin、Dai)
生活场景:估价师看了你的金表,说“市价 1 万,但为了防止跌价,最多只能借你 6900”。你说借 5000。账房在账本上记下“某某借款 5000”,然后你拿着单子去现金柜台领钱。
对应到合约:
- Spotter(估价师)根据预言机价格和抵押率,算出每 1 ETH 最多能借多少,写进账本;
- 你调用 Vat.frob 借钱,账本检查没超额度后,在你账上记一笔 DAI;
- 你通过 DaiJoin(现金柜台)把账上的 DAI 取出来,这时 Dai 合约才真正印出 DAI 到你的钱包。
Spotter:估价师
// Spotter:0x65C79fcB50Ca1594B025960e539eD7A9a6D434A3
contract Spotter is LibNote {
struct Ilk {
PipLike pip; // 价格预言机(告诉估价师市价)
uint256 mat; // 抵押率,例如 1.45 表示押 145 块钱的东西最多借 100 [ray]
}
mapping (bytes32 => Ilk) public ilks;
VatLike public vat;
uint256 public par; // 1 DAI 对应多少美元,固定为 1 [ray]
// 任何人都可以调用:读取最新市价,算出可借额度,写进账本
function poke(bytes32 ilk) external {
(bytes32 val, bool has) = ilks[ilk].pip.peek(); // 市价 [wad]
// 可借额度 spot = 市价 ÷ par ÷ 抵押率。mul(…, 10**9) 把 wad 补成 ray
uint256 spot = has ? rdiv(rdiv(mul(uint(val), 10 ** 9), par), ilks[ilk].mat) : 0;
vat.file(ilk, "spot", spot);
emit Poke(ilk, val, spot);
}
}
| 方法 / 变量 | 在做什么 | 实测(ETH-A) |
|---|---|---|
ilks(ilk) | 查预言机地址和抵押率 | mat = 1.45(145%) |
par() | 1 DAI = 多少美元 | 1 |
poke(ilk) | 更新账本上的可借额度,任何人可调用 | — |
file(…) | 管理员修改预言机、抵押率 | — |
算一下:实测时账本上 ETH-A 的 spot = 1618.30,也就是每 1 ETH 最多借 1618.30 DAI。反推市价:1618.30 × 1.45 ≈ 2346.54 美元。所以押 10 ETH 最多能借约 16183 DAI。
Vat.frob:签当票的规矩
// Vat 的 frob
// i:押品种类;u:当票主人;v:押品从谁的柜台余额里出;w:借到的 DAI 记给谁
// dink:押品变化(正数多押,负数取回);dart:借款份数变化(正数借,负数还)
function frob(bytes32 i, address u, address v, address w, int dink, int dart) external note {
require(live == 1, "Vat/not-live");
Urn memory urn = urns[i][u];
Ilk memory ilk = ilks[i];
require(ilk.rate != 0, "Vat/ilk-not-init");
urn.ink = add(urn.ink, dink);
urn.art = add(urn.art, dart);
ilk.Art = add(ilk.Art, dart);
int dtab = mul(ilk.rate, dart); // 这次实际借 / 还的钱 = 份数 × rate [rad]
uint tab = mul(ilk.rate, urn.art); // 这张当票现在的实际欠款 [rad]
debt = add(debt, dtab);
// ① 要么在还钱,要么没超过借款总上限
require(either(dart <= 0, both(mul(ilk.Art, ilk.rate) <= ilk.line, debt <= Line)), "Vat/ceiling-exceeded");
// ② 要么风险变小了(还钱或多押),要么欠款 ≤ 押品 × 可借额度
require(either(both(dart <= 0, dink >= 0), tab <= mul(urn.ink, ilk.spot)), "Vat/not-safe");
// ③ 让当票更危险的操作,必须本人或有委托书的人来做
require(either(both(dart <= 0, dink >= 0), wish(u, msg.sender)), "Vat/not-allowed-u");
// ④ 多押东西时,押品主人 v 必须同意
require(either(dink <= 0, wish(v, msg.sender)), "Vat/not-allowed-v");
// ⑤ 还钱时,出钱的 w 必须同意
require(either(dart >= 0, wish(w, msg.sender)), "Vat/not-allowed-w");
// ⑥ 欠款要么为 0,要么不低于最低借款额
require(either(urn.art == 0, tab >= ilk.dust), "Vat/dust");
gem[i][v] = sub(gem[i][v], dink); // 多押:柜台余额减少;取回:柜台余额增加
dai[w] = add(dai[w], dtab); // 借:账上 DAI 增加;还:账上 DAI 减少
urns[i][u] = urn;
ilks[i] = ilk;
}
这 6 条 require 就是当铺的全部规矩,翻译成大白话:
| 规矩 | 大白话 | 违反时的报错 |
|---|---|---|
| ① | 当铺的钱借完了就不借了 | Vat/ceiling-exceeded |
| ② | 借的钱不能超过估价师给的额度 | Vat/not-safe |
| ③ | 只有当票主人(或有委托书的人)能借钱、取押品 | Vat/not-allowed-u |
| ④ | 不能拿别人的东西来押 | Vat/not-allowed-v |
| ⑤ | 不能拿别人账上的钱来还 | Vat/not-allowed-w |
| ⑥ | 小生意不做:要借就至少借够最低额度 | Vat/dust |
注意 ② 和 ③ 里的 both(dart <= 0, dink >= 0):让当票更安全的操作(还钱、多押东西),永远允许,谁来做都行。就算你的当票已经很危险了,也随时可以补救。
DaiJoin:现金柜台
frob 借到的钱只是账上的一个数字(Vat.dai),还不能在钱包里转账,要去现金柜台换成真正的 DAI:
// DaiJoin:0x9759A6Ac90977b93B58547b4A71c78317f391A28
contract DaiJoin is LibNote {
VatLike public vat;
DSTokenLike public dai; // Dai 代币合约
uint public live;
uint constant ONE = 10 ** 27; // wad × ONE = rad
// 存现金:销毁你钱包里的 DAI,换成账上的 DAI(还钱时用)
function join(address usr, uint wad) external note {
vat.move(address(this), usr, mul(ONE, wad)); // 柜台账上的 DAI 转给 usr
dai.burn(msg.sender, wad); // 销毁 msg.sender 钱包里的 DAI(需要 approve)
}
// 取现金:扣掉你账上的 DAI,给钱包印出 DAI
function exit(address usr, uint wad) external note {
require(live == 1, "DaiJoin/not-live");
vat.move(msg.sender, address(this), mul(ONE, wad)); // 你账上的 DAI 转给柜台(需要委托书 hope)
dai.mint(usr, wad); // 给 usr 印出 wad 个 DAI
}
}
Vat.move 就是账本内部的转账:
// Vat 的 move:在账本内部转 DAI
function move(address src, address dst, uint256 rad) external note {
require(wish(src, msg.sender), "Vat/not-allowed"); // 本人,或 src 签过委托书
dai[src] = sub(dai[src], rad);
dai[dst] = add(dai[dst], rad);
}
所以取现金之前,你要先签一份委托书 Vat.hope(DaiJoin),允许柜台动你账上的钱。
现金柜台有个巧妙的地方:每取出 1 DAI,柜台自己账上就多 1 DAI;每存回 1 DAI,柜台账上就少 1 DAI。所以柜台账上的 DAI 总数,和外面流通的 DAI 总数始终对得上,就像银行金库里的钱和发出去的存单一一对应。
| 方法 | 在做什么 | 前提 |
|---|---|---|
exit(usr, wad) | 取现金:账上 DAI → 钱包 DAI(调用 Dai.mint) | 先 Vat.hope(DaiJoin) |
join(usr, wad) | 存现金:钱包 DAI → 账上 DAI(调用 Dai.burn) | 先 Dai.approve(DaiJoin, …) |
cage() | 关门,不能再取现金 | 管理员 |
Dai.mint:印钞
// Dai 的 mint:只有 wards 名单里的地址能调用,主网上只有 DaiJoin
function mint(address usr, uint wad) external auth {
balanceOf[usr] = add(balanceOf[usr], wad);
totalSupply = add(totalSupply, wad);
emit Transfer(address(0), usr, wad);
}
链上查询:Dai 合约的 wards 名单里只有 DaiJoin,部署者早已把自己移出了名单。也就是说,世界上能印 DAI 的只有现金柜台,而柜台只认账本上真实借出的钱。没有任何人能凭空印 DAI。
实测(接第 1 步,U 已押 10 ETH)
| 操作 | 结果 |
|---|---|
先调用 Jug.drip("ETH-A") 更新利息 | rate = 1.39449,借 10000 DAI 需要 dart = 10000 ÷ 1.39449 ≈ 7171.10 份 |
| 借 20000 DAI(超过 16183 的额度) | 失败:Vat/not-safe |
| 借 100 DAI(低于最低额度,实测最低 7500 DAI) | 失败:Vat/dust |
Vat.frob("ETH-A", U, U, U, 0, 7171.10e18) | 成功:当票 art = 7171.10,账上 DAI ≈ 10000 |
没签委托书就 DaiJoin.exit(U, 10000e18) | 失败:Vat/not-allowed |
Vat.hope(DaiJoin) 后再 exit | 成功:钱包里有了 10000 DAI |
五、第 3 步:计息和还钱(Jug)
生活场景:当铺借钱是要付利息的。一年后你来赎金表,账房先生拨拉算盘:“本金 1 万,利息 1050,一共 11050。”你把钱交到现金柜台,账房在账本上销账,柜台把金表还给你。
对应到合约:
- Jug(账房先生)让
rate随时间增长,你的欠款art × rate跟着变大; - 你把 DAI 交给 DaiJoin,钱包里的 DAI 被销毁,变回账上的 DAI;
- 调用 Vat.frob 还钱、取回押品;
- 从 GemJoin 取回 WETH。
Jug:账房先生
// Jug:0x19c0976f590D67707E62397C87829d896Dc0f1F1
contract Jug is LibNote {
struct Ilk {
uint256 duty; // 每秒的利率,例如 1.0000000031660748 [ray]
uint256 rho; // 上次算利息的时间
}
mapping (bytes32 => Ilk) public ilks;
VatLike public vat;
address public vow; // 利息收入交给当铺金库 Vow
uint256 public base; // 所有押品共用的基础利率,目前为 0
// 任何人都可以调用:把从上次到现在的利息一次算清
function drip(bytes32 ilk) external note returns (uint rate) {
require(now >= ilks[ilk].rho, "Jug/invalid-now");
(, uint prev) = vat.ilks(ilk);
// 新 rate = 每秒利率 ^ 过了多少秒 × 旧 rate(按秒复利)
rate = rmul(rpow(add(base, ilks[ilk].duty), now - ilks[ilk].rho, ONE), prev);
vat.fold(ilk, vow, diff(rate, prev)); // 通知账本更新 rate
ilks[ilk].rho = now;
}
}
rpow 是用汇编写的“快速幂”,用来算 x^n,这里不展开。账本上对应的 fold:
// Vat 的 fold:只有授权合约(Jug)能调用
function fold(bytes32 i, address u, int rate) external note auth {
require(live == 1, "Vat/not-live");
Ilk storage ilk = ilks[i];
ilk.rate = add(ilk.rate, rate); // 每份借款的价格上涨,所有人的欠款一起变多
int rad = mul(ilk.Art, rate); // 这次产生的利息总额
dai[u] = add(dai[u], rad); // 利息记到当铺金库 Vow
debt = add(debt, rad);
}
| 方法 / 变量 | 在做什么 | 实测(ETH-A) |
|---|---|---|
ilks(ilk) | 查每秒利率和上次算利息的时间 | duty = 1.000000003166,按年复利 ≈ 10.5% |
base() | 基础利率 | 0 |
drip(ilk) | 算利息,任何人可调用 | 见下方实测 |
账房先生不会自己动:drip 需要有人调用才会执行。不过没人调用时利息也不会少算,下次调用时会按经过的秒数一次补齐。所以借钱、还钱之前,先调用一次 drip,才能拿到最新的欠款数。
还钱:把前两步倒着走一遍
Dai.approve(DaiJoin, 数额):允许现金柜台收走你的 DAI;DaiJoin.join(你, 数额):钱包里的 DAI 被销毁,变回账上的 DAI;Vat.frob(ilk, 你, 你, 你, -押品, -份数):还清欠款,同时取回押品(回到柜台余额gem);GemJoin.exit(你, 数量):从柜台取回 WETH。
第 2 步里销毁 DAI 用的是 Dai 合约的 burn:
// Dai 的 burn:销毁自己的币直接调用;销毁别人的币需要对方授权
function burn(address usr, uint wad) external {
require(balanceOf[usr] >= wad, "Dai/insufficient-balance");
if (usr != msg.sender && allowance[usr][msg.sender] != uint(-1)) {
require(allowance[usr][msg.sender] >= wad, "Dai/insufficient-allowance");
allowance[usr][msg.sender] = sub(allowance[usr][msg.sender], wad);
}
balanceOf[usr] = sub(balanceOf[usr], wad);
totalSupply = sub(totalSupply, wad);
emit Transfer(usr, address(0), wad);
}
现金柜台调用 dai.burn(你, 数额) 时,在 Dai 合约看来是“柜台要销毁你的币”,所以你得事先 approve 给柜台。
实测(接第 2 步,把时间往后拨 365 天)
| 操作 | 结果 |
|---|---|
Jug.drip("ETH-A") | rate 从 1.39449 变成 1.54091,正好涨了 10.5% |
查欠款:art × rate | 7171.10 × 1.54091 ≈ 11050 DAI(本金 10000,利息 1050) |
| 另一个账户 K 开金库借 8000 DAI,转 1500 给 U | U 有 11500 DAI,够还了 |
没 approve 就 DaiJoin.join(U, 11050e18) | 失败:Dai/insufficient-allowance |
approve 后再 join | 成功:钱包剩约 450 DAI,账上 DAI ≈ 11050 |
Vat.frob("ETH-A", U, U, U, -10e18, -7171.10e18) | 成功:当票清零,柜台余额 gem = 10 |
GemJoin.exit(U, 10e18) | 成功:U 拿回 10 WETH |
六、第 4 步:押品不值钱了,拍卖(Dog、Clipper、Abacus)
生活场景:金价暴跌,你押的金表已经不够抵债了。当铺的巡查员发现后,把金表没收,送进拍卖行。拍卖行挂出一个比市价高的价格,然后每隔一会儿降一次价,就像菜市场傍晚收摊前的甩卖,谁先觉得划算谁就买走。卖来的钱先还你的债(外加一笔罚金),如果还有剩,剩下的押品还给你。
对应到合约:
- Dog(巡查员)发现当票的欠款超过了押品的可借额度,调用
bark没收押品; - Clipper(拍卖行)开始拍卖,并奖励发现问题的人;
- Abacus(降价牌)决定价格怎么降;
- 买家调用
Clipper.take买走押品。
谁来当巡查员? 任何人都可以。MakerDAO 给发现危险当票的人发奖金,所以网上有很多自动运行的“清算机器人”(keeper)一直盯着,一有危险当票就抢着调用 bark。
Dog:巡查员
// Dog:0x135954d155898D42C90D2a57824C690e0c7BEf1B
contract Dog {
struct Ilk {
address clip; // 这种押品的拍卖行
uint256 chop; // 罚金系数,例如 1.13 表示加罚 13% [wad]
uint256 hole; // 这种押品同时在拍卖的金额上限 [rad]
uint256 dirt; // 这种押品正在拍卖的金额 [rad]
}
mapping (bytes32 => Ilk) public ilks;
uint256 public Hole; // 全当铺同时在拍卖的金额上限 [rad]
uint256 public Dirt; // 全当铺正在拍卖的金额 [rad]
// 任何人都可以调用:没收 urn 这张当票的押品,奖金发给 kpr
function bark(bytes32 ilk, address urn, address kpr) external returns (uint256 id) {
require(live == 1, "Dog/not-live");
(uint256 ink, uint256 art) = vat.urns(ilk, urn);
Ilk memory milk = ilks[ilk];
uint256 dart;
uint256 rate;
uint256 dust;
{
uint256 spot;
(,rate, spot,, dust) = vat.ilks(ilk);
// 只有危险的当票才能没收:押品 × 可借额度 < 欠款
require(spot > 0 && mul(ink, spot) < mul(art, rate), "Dog/not-unsafe");
// 同时拍卖的总额有上限,防止一下子抛售太多押品把价格砸穿
require(Hole > Dirt && milk.hole > milk.dirt, "Dog/liquidation-limit-hit");
uint256 room = min(Hole - Dirt, milk.hole - milk.dirt);
dart = min(art, mul(room, WAD) / rate / milk.chop); // 这次没收多少债务
// 只没收一部分时,剩下的不能低于最低借款额
if (art > dart) {
if (mul(art - dart, rate) < dust) {
dart = art;
} else {
require(mul(dart, rate) >= dust, "Dog/dusty-auction-from-partial-liquidation");
}
}
}
uint256 dink = mul(ink, dart) / art; // 按比例没收押品
require(dink > 0, "Dog/null-auction");
require(dart <= 2**255 && dink <= 2**255, "Dog/overflow");
// 让账本把押品转给拍卖行,这笔债先记成当铺的坏账,等拍卖收回
vat.grab(
ilk, urn, milk.clip, address(vow), -int256(dink), -int256(dart)
);
uint256 due = mul(dart, rate); // 被没收的实际欠款
vow.fess(due);
{
uint256 tab = mul(due, milk.chop) / WAD; // 拍卖要收回的钱 = 欠款 × 罚金系数
Dirt = add(Dirt, tab);
ilks[ilk].dirt = add(milk.dirt, tab);
id = ClipperLike(milk.clip).kick({ // 通知拍卖行开拍
tab: tab,
lot: dink,
usr: urn,
kpr: kpr
});
}
emit Bark(ilk, urn, dink, dart, due, milk.clip, id);
}
}
账本上对应的 grab,可以理解为“强制版的 frob”:不检查任何规矩,只有巡查员能调用:
// Vat 的 grab:只有授权合约(Dog)能调用
function grab(bytes32 i, address u, address v, address w, int dink, int dart) external note auth {
Urn storage urn = urns[i][u];
Ilk storage ilk = ilks[i];
urn.ink = add(urn.ink, dink); // dink 为负:没收押品
urn.art = add(urn.art, dart); // dart 为负:划掉欠款
ilk.Art = add(ilk.Art, dart);
int dtab = mul(ilk.rate, dart);
gem[i][v] = sub(gem[i][v], dink); // 押品转给 v(拍卖行)
sin[w] = sub(sin[w], dtab); // 欠款记成 w(当铺金库 Vow)的坏账
vice = sub(vice, dtab); // 当铺坏账总额
}
| 方法 / 变量 | 在做什么 | 实测(ETH-A) |
|---|---|---|
ilks(ilk) | 查拍卖行、罚金、拍卖额度 | 罚金 chop = 1.13(13%),同时拍卖上限 4000 万 DAI |
Hole() / Dirt() | 全当铺拍卖上限 / 正在拍卖的金额 | 上限 1.5 亿 DAI,当时没有正在进行的拍卖 |
bark(ilk, urn, kpr) | 没收危险当票的押品,任何人可调用 | 见下方实测 |
digs(ilk, rad) | 拍卖收回钱后减少 Dirt,只有拍卖行调用 | — |
Clipper:拍卖行
Clipper 用的是荷兰式拍卖:起拍价比市价高,然后不断降价,第一个出手的人成交。好处是不用多轮竞价,一笔交易就能买走。
// Clipper(ETH-A):0xc67963a226eddd77B91aD8c421630A1b0AdFF270
contract Clipper {
uint256 public buf; // 起拍价 = 市价 × buf [ray]
uint256 public tail; // 拍了这么多秒还没卖完,就要重新开拍 [秒]
uint256 public cusp; // 价格降到起拍价的这个比例以下,也要重拍 [ray]
uint64 public chip; // 给巡查员的奖金:按拍卖金额的百分比 [wad]
uint192 public tip; // 给巡查员的奖金:固定金额 [rad]
struct Sale {
uint256 pos; // 在进行中拍卖列表里的位置
uint256 tab; // 还要收回多少钱 [rad]
uint256 lot; // 还剩多少押品 [wad]
address usr; // 当票主人
uint96 tic; // 开拍时间
uint256 top; // 起拍价 [ray]
}
mapping(uint256 => Sale) public sales;
// 开拍:只有巡查员 Dog 能调用
function kick(uint256 tab, uint256 lot, address usr, address kpr)
external auth lock isStopped(1) returns (uint256 id)
{
require(tab > 0, "Clipper/zero-tab");
require(lot > 0, "Clipper/zero-lot");
require(usr != address(0), "Clipper/zero-usr");
id = ++kicks;
require(id > 0, "Clipper/overflow");
active.push(id);
sales[id].pos = active.length - 1;
sales[id].tab = tab;
sales[id].lot = lot;
sales[id].usr = usr;
sales[id].tic = uint96(block.timestamp);
uint256 top;
top = rmul(getFeedPrice(), buf); // 起拍价 = 预言机市价 × buf
require(top > 0, "Clipper/zero-top-price");
sales[id].top = top;
// 发奖金:固定金额 tip + 拍卖金额 × chip,直接记到巡查员 kpr 的账上
uint256 _tip = tip;
uint256 _chip = chip;
uint256 coin;
if (_tip > 0 || _chip > 0) {
coin = add(_tip, wmul(tab, _chip));
vat.suck(vow, kpr, coin);
}
emit Kick(id, top, tab, lot, usr, kpr, coin);
}
买家出价用 take(为了好读,省略了两段细节):
// 以不超过 max 的单价,买最多 amt 个押品,押品给 who
function take(uint256 id, uint256 amt, uint256 max, address who, bytes calldata data)
external lock isStopped(3)
{
address usr = sales[id].usr;
uint96 tic = sales[id].tic;
require(usr != address(0), "Clipper/not-running-auction");
uint256 price;
{
bool done;
(done, price) = status(tic, sales[id].top); // 现在的价格
require(!done, "Clipper/needs-reset"); // 拍太久或降太多,要先重拍
}
require(max >= price, "Clipper/too-expensive"); // 现价不能高于买家愿意出的价
uint256 lot = sales[id].lot;
uint256 tab = sales[id].tab;
uint256 owe;
{
uint256 slice = min(lot, amt); // 这次买多少押品
owe = mul(slice, price); // 要付多少钱
if (owe > tab) { // 收够欠款就不多收了
owe = tab;
slice = owe / price;
} else if (owe < tab && slice < lot) {
// ……省略:剩下的欠款不能太零碎……
}
tab = tab - owe;
lot = lot - slice;
vat.flux(ilk, address(this), who, slice); // 押品转给买家(记在账本上)
DogLike dog_ = dog;
// ……省略:可选的回调,方便买家用闪电贷来买……
vat.move(msg.sender, vow, owe); // 买家账上的 DAI 付给当铺金库
dog_.digs(ilk, lot == 0 ? tab + owe : owe);
}
if (lot == 0) {
_remove(id); // 押品卖完了,拍卖结束
} else if (tab == 0) {
vat.flux(ilk, address(this), usr, lot); // 欠款收够了,剩下的押品还给原主人
_remove(id);
} else {
sales[id].tab = tab;
sales[id].lot = lot;
}
emit Take(id, max, price, owe, tab, lot, usr);
}
// 现价由降价牌 Abacus 计算;拍太久或价格降太多时需要重拍
function status(uint96 tic, uint256 top) internal view returns (bool done, uint256 price) {
price = calc.price(top, sub(block.timestamp, tic));
done = (sub(block.timestamp, tic) > tail || rdiv(price, top) < cusp);
}
注意 tab == 0 那个分支:欠款收够后,剩下的押品会还给原主人。被清算不等于押品全没了,你损失的是“欠款 + 13% 罚金”对应的那部分。
| 方法 | 在做什么 | 谁能调用 | 实测(ETH-A 参数) |
|---|---|---|---|
kick(…) | 开拍并发奖金 | Dog | 起拍价为市价的 110%(buf = 1.1);奖金 = 250 DAI + 拍卖金额的 0.1% |
take(id, amt, max, who, data) | 按现价买押品 | 任何人(先 Vat.hope(Clipper)) | — |
redo(id, kpr) | 拍太久或降太多时,按最新市价重拍 | 任何人 | 超过 2 小时或价格低于起拍价的 45% 时需要重拍 |
getStatus(id) | 查现价、剩余押品和欠款 | 任何人(只读) | — |
sales(id) / list() / count() | 查拍卖详情 / 进行中的拍卖 | 任何人(只读) | 实测时 ETH-A 已经拍卖过 937 场 |
yank(id) | 取消拍卖 | 管理员 | — |
file(…) | 改参数,包括紧急暂停开关 stopped | 管理员 | stopped = 0(未暂停) |
Abacus:降价牌
// Abacus(ETH-A):0x7d9f92DAa9254Bbd1f479DBE5058f74C2381A898
contract StairstepExponentialDecrease is Abacus {
uint256 public step; // 每隔多少秒降一次价 [秒]
uint256 public cut; // 每次降价后乘的系数,0.99 表示降 1% [ray]
// top:起拍价;dur:已经拍了多少秒
// 返回 top × cut ^ (dur / step),像台阶一样一级一级往下降
function price(uint256 top, uint256 dur) override external view returns (uint256) {
return rmul(top, rpow(cut, dur / step, RAY));
}
}
实测 ETH-A:step = 90,cut = 0.99,即每 90 秒降价 1%。
实测(U 重新押 10 ETH,借到额度的 99.5%,再让利息把它“压过线”)
| 操作 | 结果 |
|---|---|
| U 借约 16102 DAI(额度 16183) | 当票 art = 10449.76 份 |
这时 K 调用 Dog.bark | 失败:Dog/not-unsafe(当票还安全) |
时间往后拨 60 天,调用 Jug.drip | rate 变成 1.56641,欠款 ≈ 16368.58 DAI,超过了 16183 的额度 |
K 调用 Dog.bark("ETH-A", U, K) | 成功:U 的当票清零,第 938 号拍卖开始 |
查 Clipper.sales(938) | 要收回 16368.58 × 1.13 ≈ 18496.50 DAI;押品 10 ETH;起拍价 2346.54 × 1.1 ≈ 2581.19 |
| 查 K 的账上 DAI | 多了 268.50 DAI 奖金 = 250 + 18496.50 × 0.1% |
等 15 分钟(10 个 90 秒),getStatus(938) | 现价 = 2581.19 × 0.99^10 ≈ 2334.39 |
K 把 DAI 存回现金柜台,Vat.hope(Clipper),take(938, 1 ETH, 2334.39, K, "") | 成功:K 花 2334.39 DAI 买到 1 ETH;拍卖还剩 9 ETH,还要收回 16162.11 DAI |
K 调用 GemJoin.exit(K, 1e18) | K 拿到 1 WETH |
七、Dai.sol:当铺发的钞票
前面 4 步里,Dai 合约只出场了两次:借钱时现金柜台调用 mint 印钞,还钱时调用 burn 销毁。但钱借到手以后,转账、授权、在交易所买卖,用的都是它。它就是一个标准的 ERC20 代币,外加一个签名授权 permit。
地址:
0x6B175474E89094C44Da98b954EedeAC495271d0F。下面是完整源码,英文注释已换成中文。这份中文注释版重新编译过,字节码与链上合约一致(不含 metadata 哈希)。源码里有两行/* … */是被注释掉的代码,不起作用。
// 整个文件包含两个合约:LibNote(日志工具)和 Dai(代币本身),Dai 继承 LibNote
pragma solidity =0.5.12;
// ===== 第一个合约:LibNote,日志工具 =====
// 开源协议:GPL-3.0(原文为英文许可证声明,此处省略)
/* pragma solidity 0.5.12; */ // 被注释掉的代码,不起作用
/**
* @title LibNote 通用调用日志
* @dev 提供 note 修饰器:被修饰的函数执行完后,自动把“谁调用了哪个函数、传了什么参数”写进日志。
* MakerDAO 的很多合约都用它记录管理操作,省得为每个函数单独定义事件。
*/
contract LibNote {
// anonymous 表示匿名事件:不把事件签名写进 topic,从而腾出一个 topic 位置多放一个参数
event LogNote(
bytes4 indexed sig, // 被调用函数的选择器(前 4 字节)
address indexed usr, // 调用者
bytes32 indexed arg1, // 第 1 个参数
bytes32 indexed arg2, // 第 2 个参数
bytes data // 调用数据(固定取 224 字节)
) anonymous;
modifier note {
_;
// 用内联汇编直接写日志,比 emit 更省 gas,且一个修饰器能适配任意函数
assembly {
let mark := msize // 取内存末尾,保证这段内存是全 0
mstore(0x40, add(mark, 288)) // 更新空闲内存指针,预留 288 字节
mstore(mark, 0x20) // bytes 类型的数据偏移量
mstore(add(mark, 0x20), 224) // bytes 长度:224 字节
calldatacopy(add(mark, 0x40), 0, 224) // 把调用数据的前 224 字节拷进内存
log4(mark, 288, // 写日志:数据部分
shl(224, shr(224, calldataload(0))), // topic1:函数选择器 msg.sig
caller, // topic2:调用者 msg.sender
calldataload(4), // topic3:第 1 个参数
calldataload(36) // topic4:第 2 个参数
)
}
}
}
// ===== 第二个合约:Dai,代币本身 =====
// Copyright (C) 2017, 2018, 2019 dbrock, rain, mrchico
// 开源协议:AGPL-3.0(原文为英文许可证声明,此处省略)
/* pragma solidity 0.5.12; */
/* import "./lib.sol"; */ // 被注释掉的代码,不起作用
/**
* @title Dai 稳定币代币
* @dev 只负责“记账”:余额、转账、授权、铸造、销毁、签名授权。
* DAI 怎么铸造出来(抵押、清算等)由 MakerDAO 的其他合约决定,它们通过 auth 权限调用 mint。
*/
contract Dai is LibNote {
// --- 权限管理 ---
// wards:拥有管理权限的地址表,1 表示有权限,0 表示没有(MakerDAO 的命名习惯:ward 意为“监护人”)
mapping (address => uint) public wards;
// 授予权限(rely:信任)。只有已有权限的地址才能调用
function rely(address guy) external note auth { wards[guy] = 1; }
// 撤销权限(deny:拒绝)
function deny(address guy) external note auth { wards[guy] = 0; }
// 权限检查修饰器,失败时返回错误信息 "Dai/not-authorized"
modifier auth {
require(wards[msg.sender] == 1, "Dai/not-authorized");
_;
}
// --- ERC20 数据 ---
// 名称、符号、版本、小数位都是常量,写死在代码里,部署时不需要传入
string public constant name = "Dai Stablecoin";
string public constant symbol = "DAI";
string public constant version = "1";
uint8 public constant decimals = 18;
// 总供应量
uint256 public totalSupply;
// 余额表:地址 => 余额。变量名直接叫 balanceOf,public 自动生成的 getter 恰好就是 ERC20 的 balanceOf()
mapping (address => uint) public balanceOf;
// 授权表:持有人 => 被授权人 => 额度。同理,自动生成 ERC20 的 allowance()
mapping (address => mapping (address => uint)) public allowance;
// 每个地址的签名授权计数器,防止同一个签名被重复使用(见 permit)
mapping (address => uint) public nonces;
// ERC20 标准事件
event Approval(address indexed src, address indexed guy, uint wad);
event Transfer(address indexed src, address indexed dst, uint wad);
// --- 数学运算 ---
// 0.5.x 还没有内置溢出检查,这里用两个小函数代替 SafeMath
// 加法:结果比 x 小说明溢出
function add(uint x, uint y) internal pure returns (uint z) {
require((z = x + y) >= x);
}
// 减法:结果比 x 大说明下溢(不够减)
function sub(uint x, uint y) internal pure returns (uint z) {
require((z = x - y) <= x);
}
// --- EIP-712 签名相关 ---
// 域分隔符:把“哪条链、哪个合约”编码进签名,防止签名被拿到别的链或别的合约上重放
bytes32 public DOMAIN_SEPARATOR;
// 签名数据结构的类型哈希,值等于下面这行注释的计算结果,直接写成常量以节省 gas
// bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address holder,address spender,uint256 nonce,uint256 expiry,bool allowed)");
bytes32 public constant PERMIT_TYPEHASH = 0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb;
/**
* @dev 构造函数:部署者获得管理权限,并计算域分隔符。
* @param chainId_ 链 ID,以太坊主网为 1
*/
constructor(uint256 chainId_) public {
wards[msg.sender] = 1;
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name)),
keccak256(bytes(version)),
chainId_,
address(this)
));
}
// --- 代币核心功能 ---
/**
* @dev 转账:直接复用 transferFrom,付款人就是调用者自己。
* @return 成功返回 true
*/
function transfer(address dst, uint wad) external returns (bool) {
return transferFrom(msg.sender, dst, wad);
}
/**
* @dev 从 src 转给 dst。所有转账最终都走这里。
* @param src 付款地址
* @param dst 收款地址(注意:没有禁止零地址)
* @param wad 数量(18 位小数的最小单位)
* @return 成功返回 true
*/
function transferFrom(address src, address dst, uint wad)
public returns (bool)
{
require(balanceOf[src] >= wad, "Dai/insufficient-balance");
// 两种情况不检查、不扣减额度:
// 1. 付款人就是调用者自己(转自己的钱不需要授权)
// 2. 额度是 uint(-1),即最大值,表示“无限授权”
if (src != msg.sender && allowance[src][msg.sender] != uint(-1)) {
require(allowance[src][msg.sender] >= wad, "Dai/insufficient-allowance");
allowance[src][msg.sender] = sub(allowance[src][msg.sender], wad);
}
balanceOf[src] = sub(balanceOf[src], wad);
balanceOf[dst] = add(balanceOf[dst], wad);
emit Transfer(src, dst, wad);
return true;
}
/**
* @dev 铸造:给 usr 凭空增加 wad 枚 DAI。只有拥有权限(wards)的地址能调用。
*/
function mint(address usr, uint wad) external auth {
balanceOf[usr] = add(balanceOf[usr], wad);
totalSupply = add(totalSupply, wad);
emit Transfer(address(0), usr, wad);
}
/**
* @dev 销毁:减少 usr 的余额和总供应量。不需要管理权限:
* 销毁自己的币可以直接调用,销毁别人的币需要对方授权(规则与 transferFrom 相同)。
*/
function burn(address usr, uint wad) external {
require(balanceOf[usr] >= wad, "Dai/insufficient-balance");
if (usr != msg.sender && allowance[usr][msg.sender] != uint(-1)) {
require(allowance[usr][msg.sender] >= wad, "Dai/insufficient-allowance");
allowance[usr][msg.sender] = sub(allowance[usr][msg.sender], wad);
}
balanceOf[usr] = sub(balanceOf[usr], wad);
totalSupply = sub(totalSupply, wad);
emit Transfer(usr, address(0), wad);
}
/**
* @dev 授权 usr 可以动用调用者 wad 数量的 DAI(直接覆盖旧额度)。
* @return 成功返回 true
*/
function approve(address usr, uint wad) external returns (bool) {
allowance[msg.sender][usr] = wad;
emit Approval(msg.sender, usr, wad);
return true;
}
// --- 别名方法 ---
// 都只是 transferFrom 的简写,名字更直观,没有返回值
// push:把自己的币推给 usr
function push(address usr, uint wad) external {
transferFrom(msg.sender, usr, wad);
}
// pull:从 usr 那里把币拉给自己(需要 usr 授权)
function pull(address usr, uint wad) external {
transferFrom(usr, msg.sender, wad);
}
// move:把币从 src 移到 dst(src 不是自己时需要授权)
function move(address src, address dst, uint wad) external {
transferFrom(src, dst, wad);
}
// --- 签名授权 ---
/**
* @dev 凭持有人的链下签名完成授权,持有人自己不用发交易、不用付 gas。
* @param holder 持有人(签名者)
* @param spender 被授权人
* @param nonce 持有人当前的 nonces 值,每用一次加 1
* @param expiry 过期时间(秒级时间戳),0 表示永不过期
* @param allowed true 表示授权无限额度,false 表示把额度清零
* @param v,r,s 签名的三个组成部分
*/
function permit(address holder, address spender, uint256 nonce, uint256 expiry,
bool allowed, uint8 v, bytes32 r, bytes32 s) external
{
// 按 EIP-712 规则重新计算签名内容的哈希
bytes32 digest =
keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH,
holder,
spender,
nonce,
expiry,
allowed))
));
require(holder != address(0), "Dai/invalid-address-0");
// ecrecover 从签名反推出签名者地址,必须等于 holder
require(holder == ecrecover(digest, v, r, s), "Dai/invalid-permit");
require(expiry == 0 || now <= expiry, "Dai/permit-expired");
// 校验 nonce 后立即加 1,同一个签名只能用一次
require(nonce == nonces[holder]++, "Dai/invalid-nonce");
// 只有两种结果:无限额度或 0
uint wad = allowed ? uint(-1) : 0;
allowance[holder][spender] = wad;
emit Approval(holder, spender, wad);
}
}
查询方法(只读,不花 gas)
| 方法 | 在做什么 | 返回值 | 实测 |
|---|---|---|---|
name() / symbol() / version() / decimals() | 名称 / 符号 / 版本 / 小数位 | "Dai Stablecoin" / "DAI" / "1" / 18 | ✅ |
totalSupply() | 总供应量 | 数量 | 约 45.07 亿 DAI |
balanceOf(地址) | 查余额 | 余额 | 第 2 步借钱后 U 为 10000 DAI |
allowance(持有人, 被授权人) | 查授权额度 | 剩余额度 | A 授权 C 50 个,C 用掉 20 后剩 30 |
wards(地址) | 查能不能印钞 | 1 / 0 | DaiJoin 为 1,部署者为 0 |
nonces(地址) | 查 permit 用过几次 | 次数 | 每成功调用一次 permit 加 1 |
DOMAIN_SEPARATOR() / PERMIT_TYPEHASH() | permit 签名用的两个哈希 | 哈希值 | — |
操作方法(要发交易)
| 方法 | 在做什么 | 返回值 | 谁能调用 | 实测 |
|---|---|---|---|---|
transfer(dst, wad) | 转账 | true | 任何人 | 余额不足报 Dai/insufficient-balance |
transferFrom(src, dst, wad) | 代人转账,所有转账最终都走这里 | true | 任何人(src 不是自己时需要授权) | 没授权报 Dai/insufficient-allowance |
approve(usr, wad) | 授权 | true | 任何人 | — |
mint(usr, wad) | 印钞 | 无 | 只有 wards(DaiJoin) | 借钱时由现金柜台触发 |
burn(usr, wad) | 销毁 | 无 | 任何人(销毁别人的币需要授权) | 还钱时由现金柜台触发 |
push / pull / move | transferFrom 的三个别名 | 无 | 同 transferFrom | — |
rely / deny | 增删能印钞的地址 | 无 | wards | 部署者已移除自己 |
permit(holder, spender, nonce, expiry, allowed, v, r, s) | 凭签名授权,持有人不用发交易 | 无 | 任何人(拿着签名就行) | 同一个签名只能用一次 |
几个要点:
- 两种情况不扣授权额度:转自己的币不需要授权;额度是最大值
uint(-1)时视为“无限授权”。 permit是什么? 平时用 DAI 在交易所兑换,要先发一笔approve交易,再发一笔兑换交易,付两次 gas。有了permit,你只要在钱包里签个名(不花 gas),交易所把“授权 + 兑换”放进一笔交易里完成。注意 DAI 的permit是早期版本,只能授权“无限额度”或“清零”,和后来的 EIP-2612 标准参数不同。note修饰器会在函数执行后自动写一条日志,记录调用了哪个函数、谁调用的、参数是什么。Vat、Join、Jug、Spotter 里的note都是这个作用。
八、当铺里的其他角色
前面讲的是借钱这条主线。一家当铺要长期运转,还需要金库、保安、董事会等角色。这些合约这里只简单介绍,感兴趣可以去 Etherscan 看源码:
| 当铺里的角色 | 合约 | 以太坊主网地址 | 它做什么 |
|---|---|---|---|
| 🏦 当铺金库 | Vow | 0xA950524441892A31ebddF91d3cEEFa04Bf454466 | 收利息、收罚金、记坏账,管理当铺的盈亏 |
| 💰 储蓄柜台 | Pot | 0x197E90f9FAD81970bA7976f33CbD77088E5D7cf7 | 存 DAI 赚利息(DAI 储蓄利率 DSR),实测年化约 1.25% |
| 📈 盈余分配 | Splitter + Flapper | 0xBF7111F13386d23cb2Fba5A538107A73f6872bCF、0x374D9c3d5134052Bc558F432Afa1df6575f07407 | 当铺赚钱多了,拿盈余去市场上买回治理代币 |
| 📉 坏账补窟窿 | Flopper | 0xA41B6EF151E06da0e34B009B86E828308986736D | 拍卖收回的钱不够还债时,增发治理代币卖掉来补 |
| 📡 价格播报员 | OSM(PIP_ETH) | 0x81FE72B5A8d1A857d176C3E7d5Bd2679A9B85763 | 给估价师提供 ETH 价格;价格延迟一段时间才生效,留出反应时间,防止被瞬间操纵 |
| 💱 兑换窗口 | PSM / LitePSM(USDC) | 0x89B78CfA322F6C5dE0aBcEecab66Aee45393cC5A、0xf6e72Db5454dd049d0788e411b06CfAF16853042 | USDC 和 DAI 按 1:1 互换,帮助 DAI 价格贴近 1 美元 |
| 🗳️ 董事会 | Chief | 0x929d9A1435662357F54AdcF64DcEE4d6b867a6f9 | 治理代币持有人投票,决定利率、抵押率等参数 |
| ⏳ 公示期 | Pause | 0xbE286431454714F511008713973d3B053A2d38f3 | 投票通过的提案要等一段时间才能执行,给大家留出反应时间 |
| 🚪 紧急关门 | ESM + End | 0x09e05fF6142F2f9de8B6B65855A1d56B6cfE4c58、0x0e2e8F1D1326A4B9633D96222Ce399c708B19c28 | 出现严重问题时关停整个系统,让所有人按比例拿回抵押品 |
治理代币原本是 MKR。Sky 升级后,Chainlog 里的治理代币地址(
MCD_GOV)已经指向新的 SKY 代币(0x56072C95FAA701256059aa122697B133aDEd9279)。
九、动手:自己借一笔 DAI
光看代码不过瘾的话,可以自己在电脑上借一笔。下面用 Foundry 的 anvil 把以太坊主网复制到本地,所有操作只发生在你的电脑上,不花真钱。
准备:安装 Foundry(提供 anvil 和 cast 两个命令):
curl -L https://foundry.paradigm.xyz | bash && foundryup
启动本地主网分叉(保持这个终端开着):
anvil --fork-url https://ethereum-rpc.publicnode.com
新开一个终端,依次执行:
# anvil 自带的第 1 个测试账户(只能用于本地测试,不要往里面转真钱)
export ETH_RPC_URL=http://127.0.0.1:8545
KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
ME=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
# 合约地址
VAT=0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B
GEMJOIN=0x2F0b23f53734252Bda2277357e97e1517d6B042A
DAIJOIN=0x9759A6Ac90977b93B58547b4A71c78317f391A28
JUG=0x19c0976f590D67707E62397C87829d896Dc0f1F1
WETH=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
DAI=0x6B175474E89094C44Da98b954EedeAC495271d0F
ILK=$(cast format-bytes32-string ETH-A)
FROB='frob(bytes32,address,address,address,int256,int256)'
# 第 1 步:把 10 ETH 换成 WETH,交给押品柜台,再押上
cast send $WETH 'deposit()' --value 10ether --private-key $KEY
cast send $WETH 'approve(address,uint256)' $GEMJOIN $(cast max-uint) --private-key $KEY
cast send $GEMJOIN 'join(address,uint256)' $ME 10000000000000000000 --private-key $KEY
cast send $VAT $FROB $ILK $ME $ME $ME 10000000000000000000 0 --private-key $KEY
cast call $VAT 'urns(bytes32,address)(uint256,uint256)' $ILK $ME # 看当票:押了 10 ETH,借了 0
# 第 2 步:借 10000 DAI
cast send $JUG 'drip(bytes32)' $ILK --gas-limit 500000 --private-key $KEY # 先算一下利息
RATE=$(cast call $VAT 'ilks(bytes32)(uint256,uint256,uint256,uint256,uint256)' $ILK | sed -n 2p | awk '{print $1}')
DART=$(python3 -c "print((10000 * 10**45 + $RATE - 1) // $RATE)") # 10000 DAI 折合多少份
cast send $VAT $FROB $ILK $ME $ME $ME 0 $DART --private-key $KEY
cast send $VAT 'hope(address)' $DAIJOIN --private-key $KEY # 给现金柜台签委托书
cast send $DAIJOIN 'exit(address,uint256)' $ME 10000000000000000000000 --private-key $KEY
cast call $DAI 'balanceOf(address)(uint256)' $ME # 10000000000000000000000,即 10000 DAI
成功后,可以继续试试:
- 把
DART改成借 20000 DAI,看看Vat/not-safe报错; - 用
cast rpc evm_increaseTime 31536000把时间往后拨一年,再drip,看看欠款涨了多少; - 按第五章的顺序把钱还上,取回 WETH。
注意:主网参数会随治理投票变化,你看到的价格、利率、最低借款额可能和本文不同。公共节点有时会限流,跑得慢或失败时可以换一个 RPC 地址。
十、新手容易踩的坑
art不是欠款。实际欠款是art × rate。还钱前先调用Jug.drip更新利息,否则算出来的欠款偏少,还不清。- 分清两种 DAI。账本上的 DAI(
Vat.dai,单位 rad,10^45)和钱包里的 DAI(Dai.balanceOf,单位 wad,10^18)不是一回事,两者只能通过现金柜台 DaiJoin 互换。 - 两种授权别搞混。取现金要给账本签委托书
Vat.hope(DaiJoin);存现金要给 Dai 合约授权Dai.approve(DaiJoin);参与拍卖要Vat.hope(Clipper)。缺哪个就报哪个错:Vat/not-allowed或Dai/insufficient-allowance。 - 最低借款额。实测 ETH-A 每张当票至少要借 7500 DAI,借少了报
Vat/dust。还钱时也一样:要么全部还清,要么剩下的不低于这个数。 - 调用
drip时 gas 估算可能偏低。钱包估算 gas 时,这一秒的利息可能还是 0;交易真正打包时时间往后推了一点,要写入新利率,gas 就不够了,交易失败。手动调高 gas 上限即可(上面的命令里用了--gas-limit 500000)。 - DAI 允许转到零地址。转进去的币永久丢失,但
totalSupply不会减少。要销毁请用burn。 - 无限授权要谨慎。
approve最大值或permit(allowed = true)都等于把全部余额交给对方支配,只授权给可信的合约。
十一、DAI 和 USDT 有什么不同
两者都是锚定 1 美元的稳定币,但一个像商场储值卡,一个像当铺借款:USDT 是中心化稳定币,DAI 是去中心化稳定币。
设计理念上的区别
| 对比项 | USDT(中心化) | DAI(去中心化) |
|---|---|---|
| 生活中的比喻 | 商场储值卡:交钱给公司换卡 | 当铺借款:押东西借钱,按规矩办事 |
| 谁来发行 | Tether 公司 | 任何人按规则抵押借出,规则由 MakerDAO(现 Sky)社区投票决定 |
| 怎么产生新币 | 用户把美元交给公司,公司调用 issue 增发 | 用户押入抵押品,Vat.frob 检查通过后,DaiJoin.exit 调用 Dai.mint |
| 怎么减少 | 公司调用 redeem 销毁 | 用户还钱时 DaiJoin.join 调用 Dai.burn |
| 价值来源 | 公司持有的现金、国债等储备,需要相信公司的审计报告 | 链上抵押资产,任何人都能在 Vat 里实时查到 |
| 抵押品不够怎么办 | 不涉及(由公司兜底) | Dog 没收 + Clipper 拍卖,自动执行 |
| 主要风险 | 公司是否真有足额储备、被监管冻结 | 抵押品价格暴跌、清算不及时、治理决策失误;另外 DAI 的抵押品中曾有相当比例是 USDC 等中心化稳定币 |
| 能否冻结用户 | 能(黑名单 + 销毁黑名单资金) | 不能,Dai 合约里没有黑名单 |
| 能否暂停转账 | 能(pause) | Dai 合约里没有暂停开关 |
代码层面的区别
| 对比项 | USDT(TRON 版) | DAI(ETH 版) |
|---|---|---|
| 代码组成 | 1 个代币合约(约 900 行,大部分是继承来的功能) | 代币合约很简单(Dai.sol,不到 200 行),复杂逻辑分散在 Vat、Jug、Dog、Clipper 等合约里 |
| 谁能印币 | owner(实际由多签钱包担任) | 只有 DaiJoin,而 DaiJoin 只认账本上的真实借款 |
| 权限模型 | 单一 owner | 每个合约都有 wards 管理员名单;用户之间用 Vat.hope 签委托书 |
| 能否升级 | 能(deprecate 转发到新合约) | 合约不可升级;要改规则就换掉某个模块(比如 2021 年用 Dog、Clipper 替换了旧的清算模块) |
| 小数位 | 6 | 18 |
transfer 返回值 | 永远返回 false(缺陷) | 成功返回 true,符合 ERC20 标准 |
| 转账到零地址 | 禁止 | 允许(币会丢失) |
签名授权 permit | 没有 | 有(早期版本,与 EIP-2612 参数不同) |
| Solidity 版本 | 0.4.x | 0.5.12(核心)+ 0.6.12(清算) |
一句话总结:USDT 的合约是“发行公司的管理工具”,所以有暂停、冻结、升级这些权力,代币背后的储备在链下;MakerDAO 则是一家“没有老板的当铺”,估价、放款、计息、拍卖全部写在链上的合约里,Dai.sol 只是当铺发出去的钞票。两者都很成功,代表了稳定币的两条不同路线。