适合谁读:刚开始学区块链开发、会一点 Solidity 的同学。

MakerDAO 是以太坊上最早的借贷协议之一,稳定币 DAI 就是它发行的。它的合约代码出了名的难读:变量名只有三四个字母,一个功能拆在好几个合约里。

本文换一个读法:把 MakerDAO 想象成一家开在链上的当铺,跟着“押东西、借钱、还钱、押品被拍卖”这条线,一步一步看每个合约在当铺里扮演什么角色、代码怎么写。

文中所有合约都是以太坊主网上正在运行的真实合约,附有地址,源码可以在 Etherscan 上查看。文中的“实测”数据,是 2026-10-28 在主网分叉(把主网状态复制到本地的测试环境,区块 26288434)上实际调用得到的。


目录


一、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 的每个合约,正好对应其中一个角色:

当铺里的角色合约以太坊主网地址它做什么
📒 总账本Vat0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B记录每个人押了多少东西、欠了多少钱。所有合约都围着它转
📦 押品柜台GemJoin(ETH-A)0x2F0b23f53734252Bda2277357e97e1517d6B042A收下你的 WETH,在账本上记一笔;还清后把东西退给你
🔍 估价师Spotter0x65C79fcB50Ca1594B025960e539eD7A9a6D434A3根据市价打个折,算出“这件东西最多能借多少钱”
💵 现金柜台DaiJoin0x9759A6Ac90977b93B58547b4A71c78317f391A28把账本上的钱换成现金(DAI),或把现金收回来
💴 钞票Dai0x6B175474E89094C44Da98b954EedeAC495271d0F钱包里看到的 DAI,一个 ERC20 代币
🧮 账房先生Jug0x19c0976f590D67707E62397C87829d896Dc0f1F1按秒算利息
🚨 巡查员Dog0x135954d155898D42C90D2a57824C690e0c7BEf1B发现押品价值不够还债了,就把它没收,送去拍卖
🔨 拍卖行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 / dartink / art 的变化量,可正可负这次多押 / 少押、多借 / 少借

为什么要用“份数 × 价格”来记债? 想象一下:当铺有几万张当票,如果每过一秒都要给每张当票加一点利息,账房先生会累死(在链上就是 gas 费高到付不起)。MakerDAO 的办法很聪明:每张当票只记“借了多少份”,利息只体现在一个全局数字 rate(每份值多少钱)上。账房先生每次只改这一个数字,所有人的欠款就一起涨了。这和基金很像:你持有的份额不变,净值涨了,你的资产就涨了。

还有三种数字精度,Vat 里的数字看起来特别长就是这个原因:

单位精度用在哪里
wad10^18代币数量,例如 ink、art、钱包里的 DAI
ray10^27比率,例如 rate、spot
rad10^45wad × ray 的结果,例如 Vat 里的 dai

三、第 1 步:把东西押进当铺(GemJoin、Vat)

生活场景:你走进当铺,把金表交给柜台。柜台收下金表放进保险柜,在账本上记一笔“某某交来金表一块”。然后你签当票,金表正式变成押品。

对应到合约:

  1. 你把 WETH 交给 GemJoin(押品柜台),Vat 的 gem 里给你记一笔;
  2. 你调用 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”,然后你拿着单子去现金柜台领钱。

对应到合约:

  1. Spotter(估价师)根据预言机价格和抵押率,算出每 1 ETH 最多能借多少,写进账本;
  2. 你调用 Vat.frob 借钱,账本检查没超额度后,在你账上记一笔 DAI;
  3. 你通过 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。”你把钱交到现金柜台,账房在账本上销账,柜台把金表还给你。

对应到合约:

  1. Jug(账房先生)让 rate 随时间增长,你的欠款 art × rate 跟着变大;
  2. 你把 DAI 交给 DaiJoin,钱包里的 DAI 被销毁,变回账上的 DAI;
  3. 调用 Vat.frob 还钱、取回押品;
  4. 从 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,才能拿到最新的欠款数。

还钱:把前两步倒着走一遍

  1. Dai.approve(DaiJoin, 数额):允许现金柜台收走你的 DAI;
  2. DaiJoin.join(你, 数额):钱包里的 DAI 被销毁,变回账上的 DAI;
  3. Vat.frob(ilk, 你, 你, 你, -押品, -份数):还清欠款,同时取回押品(回到柜台余额 gem);
  4. 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 × rate7171.10 × 1.54091 ≈ 11050 DAI(本金 10000,利息 1050)
另一个账户 K 开金库借 8000 DAI,转 1500 给 UU 有 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)

生活场景:金价暴跌,你押的金表已经不够抵债了。当铺的巡查员发现后,把金表没收,送进拍卖行。拍卖行挂出一个比市价高的价格,然后每隔一会儿降一次价,就像菜市场傍晚收摊前的甩卖,谁先觉得划算谁就买走。卖来的钱先还你的债(外加一笔罚金),如果还有剩,剩下的押品还给你。

对应到合约:

  1. Dog(巡查员)发现当票的欠款超过了押品的可借额度,调用 bark 没收押品;
  2. Clipper(拍卖行)开始拍卖,并奖励发现问题的人;
  3. Abacus(降价牌)决定价格怎么降;
  4. 买家调用 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.driprate 变成 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 / 0DaiJoin 为 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 / movetransferFrom 的三个别名无同 transferFrom—
rely / deny增删能印钞的地址无wards部署者已移除自己
permit(holder, spender, nonce, expiry, allowed, v, r, s)凭签名授权,持有人不用发交易无任何人(拿着签名就行)同一个签名只能用一次

几个要点:

  1. 两种情况不扣授权额度:转自己的币不需要授权;额度是最大值 uint(-1) 时视为“无限授权”。
  2. permit 是什么? 平时用 DAI 在交易所兑换,要先发一笔 approve 交易,再发一笔兑换交易,付两次 gas。有了 permit,你只要在钱包里签个名(不花 gas),交易所把“授权 + 兑换”放进一笔交易里完成。注意 DAI 的 permit 是早期版本,只能授权“无限额度”或“清零”,和后来的 EIP-2612 标准参数不同。
  3. note 修饰器会在函数执行后自动写一条日志,记录调用了哪个函数、谁调用的、参数是什么。Vat、Join、Jug、Spotter 里的 note 都是这个作用。

八、当铺里的其他角色

前面讲的是借钱这条主线。一家当铺要长期运转,还需要金库、保安、董事会等角色。这些合约这里只简单介绍,感兴趣可以去 Etherscan 看源码:

当铺里的角色合约以太坊主网地址它做什么
🏦 当铺金库Vow0xA950524441892A31ebddF91d3cEEFa04Bf454466收利息、收罚金、记坏账,管理当铺的盈亏
💰 储蓄柜台Pot0x197E90f9FAD81970bA7976f33CbD77088E5D7cf7存 DAI 赚利息(DAI 储蓄利率 DSR),实测年化约 1.25%
📈 盈余分配Splitter + Flapper0xBF7111F13386d23cb2Fba5A538107A73f6872bCF、0x374D9c3d5134052Bc558F432Afa1df6575f07407当铺赚钱多了,拿盈余去市场上买回治理代币
📉 坏账补窟窿Flopper0xA41B6EF151E06da0e34B009B86E828308986736D拍卖收回的钱不够还债时,增发治理代币卖掉来补
📡 价格播报员OSM(PIP_ETH)0x81FE72B5A8d1A857d176C3E7d5Bd2679A9B85763给估价师提供 ETH 价格;价格延迟一段时间才生效,留出反应时间,防止被瞬间操纵
💱 兑换窗口PSM / LitePSM(USDC)0x89B78CfA322F6C5dE0aBcEecab66Aee45393cC5A、0xf6e72Db5454dd049d0788e411b06CfAF16853042USDC 和 DAI 按 1:1 互换,帮助 DAI 价格贴近 1 美元
🗳️ 董事会Chief0x929d9A1435662357F54AdcF64DcEE4d6b867a6f9治理代币持有人投票,决定利率、抵押率等参数
⏳ 公示期Pause0xbE286431454714F511008713973d3B053A2d38f3投票通过的提案要等一段时间才能执行,给大家留出反应时间
🚪 紧急关门ESM + End0x09e05fF6142F2f9de8B6B65855A1d56B6cfE4c58、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 地址。


十、新手容易踩的坑

  1. art 不是欠款。实际欠款是 art × rate。还钱前先调用 Jug.drip 更新利息,否则算出来的欠款偏少,还不清。
  2. 分清两种 DAI。账本上的 DAI(Vat.dai,单位 rad,10^45)和钱包里的 DAI(Dai.balanceOf,单位 wad,10^18)不是一回事,两者只能通过现金柜台 DaiJoin 互换。
  3. 两种授权别搞混。取现金要给账本签委托书 Vat.hope(DaiJoin);存现金要给 Dai 合约授权 Dai.approve(DaiJoin);参与拍卖要 Vat.hope(Clipper)。缺哪个就报哪个错:Vat/not-allowed 或 Dai/insufficient-allowance。
  4. 最低借款额。实测 ETH-A 每张当票至少要借 7500 DAI,借少了报 Vat/dust。还钱时也一样:要么全部还清,要么剩下的不低于这个数。
  5. 调用 drip 时 gas 估算可能偏低。钱包估算 gas 时,这一秒的利息可能还是 0;交易真正打包时时间往后推了一点,要写入新利率,gas 就不够了,交易失败。手动调高 gas 上限即可(上面的命令里用了 --gas-limit 500000)。
  6. DAI 允许转到零地址。转进去的币永久丢失,但 totalSupply 不会减少。要销毁请用 burn。
  7. 无限授权要谨慎。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 替换了旧的清算模块)
小数位618
transfer 返回值永远返回 false(缺陷)成功返回 true,符合 ERC20 标准
转账到零地址禁止允许(币会丢失)
签名授权 permit没有有(早期版本,与 EIP-2612 参数不同)
Solidity 版本0.4.x0.5.12(核心)+ 0.6.12(清算)

一句话总结:USDT 的合约是“发行公司的管理工具”,所以有暂停、冻结、升级这些权力,代币背后的储备在链下;MakerDAO 则是一家“没有老板的当铺”,估价、放款、计息、拍卖全部写在链上的合约里,Dai.sol 只是当铺发出去的钞票。两者都很成功,代表了稳定币的两条不同路线。