Crypto OS
Technical Crypto OS第四阶段 · DeFi Engineering

T22 · Lending Protocol

清算引擎在什么时候必须跑赢市场?

练习的能力
BuilderFinancial Literacy
动手
实现一个最小借贷池与清算函数,构造一次价格暴跌,验证清算能否及时完成。
AI Lab
让 AI 设计利率曲线,自己模拟资金利用率从 50% 冲到 99% 时会发生什么。

一个现实问题

你的借贷池上线了一个月,运行得很好。

利率曲线是你最得意的部分:分段线性、拐点选得漂亮、利用率高时资金成本迅速上升、存款人和借款人的收益曲线在图上非常对称。你为此写了一整套单元测试,每一段的斜率都精确到小数点后六位。

然后某个下午,抵押资产在二十分钟内跌了 25%。

第二天你去对账,发现协议里出现了一笔坏账:某个仓位被清算之后,抵押品变现的钱不够还清它的债务,缺口由协议承担。

你开始复盘,逐个排查:

  • 健康度计算错了吗?没有,手算和代码完全一致。
  • 清算函数有 bug 吗?没有,它在测试里跑了几百次。
  • 价格源出问题了吗?没有,T21 的四个检查全部正常,价格一路准确地跟下来了。
  • 抵押率设得太高吗?75%,是个很常规的值。

每一个部件都正常工作。坏账还是出现了。

最后你在链上找到了真正的原因:这个仓位在 14:32 变成可清算状态,第一笔成功的清算交易发生在 14:36

四分钟。在那四分钟里,价格又跌了 12%。

你花了一个月打磨利率曲线,而决定这个协议安不安全的,是那四分钟。

思想实验

把借贷协议想成一家当铺,先不谈利率。

规则很简单:你押一件价值 100 的东西,当铺借给你 75。

问题在于,押进来的东西价格会变。如果它跌到 80,当铺还算安全;跌到 70,当铺就亏了。

当铺老板不可能 24 小时盯着每一件抵押品。他的办法是把这件事外包给市场:在门口贴一张告示——

任何人发现某件抵押品跌破了 80,可以按当前价格的 95 折把它买走,钱用来还清那笔债。

注意这群人是谁:他们不是当铺的员工,不领工资,没有义务。 他们只在有利可图的时候出现。

现在把这套机制拆开,问四个问题:

第一,他们凭什么来? 那 5% 的折扣就是全部理由。如果一件抵押品只值 200,5% 就是 10 块钱——扣掉手续费和交易成本之后,可能根本不值得跑一趟。

第二,他们来得有多快? 他们需要先发现(有人在盯着链上数据吗)、再准备资金(他得有这 190 块,或者能借到)、再发出交易、再等它被打包。

第三,别人会不会抢? 同一个机会所有人都看得见,抢到的只有一个。抢输的人白付了手续费,下次他会出更高的价来抢——这部分成本最终都算在用户头上。

第四,最关键的:那 25% 的缓冲,到底是什么?

它不是「安全垫」。它是一个时间预算

它的准确含义是:从抵押品开始下跌,到有人完成清算,这段时间里价格最多可以跌 25%。 跌得比这更快,或者清算来得比这更慢,缺口就落在当铺头上。

所以真正的问题从来不是「抵押率设多少」,而是:价格跌得快,还是清算跑得快。

你来决定

市场在快速下跌,大批仓位正在逼近清算线。作为协议设计者,你手上有四个可以调的杠杆。你先动哪一个?

观察结果

四个杠杆看起来在调不同的东西,其实都在买同一样东西:时间

杠杆它实际在买什么谁付钱副作用
降低抵押率更多的下跌预算所有借款人(效率下降)调整时会触发存量清算
提高清算奖励更快的清算响应被清算的用户清算变得更激进
部分清算用户的容错空间协议(响应变慢)小仓位可能无人清算
递增式奖励更精确的定价清算人与用户分摊引入固有延迟

把整件事收束成一个不等式,这是本章最该记住的东西:

坏账发生的条件:

    抵押品在「清算延迟」这段时间内的跌幅
                >
    清算阈值给出的缓冲 + 清算奖励占用的部分

其中:
  缓冲 = 1 - 债务 / (抵押品价值 * 清算阈值)      ← 你能设计的部分
  清算延迟 = 发现 + 决策 + 资金准备 + 打包上链    ← 你控制不了的部分

左边那一项是市场的速度,右边那一项是你的设计。安全与否,就是这两个数字比大小。

大多数人把注意力放在右边——调参数、优化公式、证明利率曲线的连续性。但事故几乎全部来自左边,而左边由两个因素决定:波动有多剧烈,和清算有多慢

于是有了这一章的那句话:借贷协议的安全边界是清算速度,不是利率模型的优雅程度。

利率模型决定协议赚多少钱。清算速度决定协议会不会死。

建立模型

先把清算延迟拆开,你才知道该优化哪一段:

  1. 价格下跌
  2. 仓位跌破阈值
  3. 有人发现
  4. 准备资金并发出交易
  5. 交易被打包
  6. 清算完成
从第二格到最后一格的时间,就是你的清算延迟。它的每一段都可以单独测量和优化。
阶段典型耗时谁能优化怎么优化
价格进入链上取决于预言机心跳协议T21 的参数选择
被人发现毫秒到分钟生态事件设计、开放的查询接口
准备资金接近零(可借入)协议支持在清算交易内借入
打包上链一个到多个区块清算人出更高的 Gas
竞争失败重试可能数倍于上面全部协议部分清算、奖励设计

最后一行最容易被忽略,也最容易在拥堵时爆炸。 十个清算人抢同一个机会,九个失败,失败的人要重新算、重新发,而价格还在跌。

现在是代码。三个核心函数。

第一,健康度。 它是整个协议的心跳:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// 所有比例用万分之一为单位(bps),避免浮点
uint256 constant BPS = 10_000;

struct Account {
    uint256 collateral;   // 抵押品数量
    uint256 debt;         // 债务本金(已按指数累计过利息)
}

/// 健康度大于等于 1 表示安全,小于 1 表示可被清算
/// 返回值同样放大 BPS 倍:10_000 就是 1.0
function healthFactor(
    uint256 collateral,
    uint256 price,              // 抵押品单价
    uint256 liquidationLtv,     // 清算阈值,bps,典型区间在 7000–8500,且每个资产可单独配置
    uint256 debt
) pure returns (uint256) {
    if (debt == 0) return type(uint256).max;
    uint256 adjusted = collateral * price * liquidationLtv / BPS;
    return adjusted * BPS / debt;
}

两个细节:

  • 清算阈值和抵押率不是一个数。 抵押率(Collateral Factor)决定你能借多少,清算阈值决定你什么时候被清算,后者必须更高。两者之间的差距是用户自己的缓冲区,也是唯一一个让用户有机会自救的空间
  • 债务要先累计利息再算健康度。 一个长期不动的仓位可能仅仅因为利息累积就跌破线,和价格无关。

第二,利率模型。 双斜率是最常见的形态:

/// 利用率 = 借出总额 / 存入总额
function utilization(uint256 borrowed, uint256 supplied) pure returns (uint256) {
    if (supplied == 0) return 0;
    return borrowed * BPS / supplied;
}

/// 拐点之前平缓,拐点之后陡峭
function borrowRate(
    uint256 u,           // 利用率,bps
    uint256 base,        // 基础利率
    uint256 slope1,      // 拐点之前的斜率
    uint256 slope2,      // 拐点之后的斜率,通常比 slope1 大一个数量级
    uint256 kink         // 拐点,典型区间在 8000–9000 bps,可配置
) pure returns (uint256) {
    if (u <= kink) {
        return base + u * slope1 / kink;
    }
    uint256 excess = u - kink;
    return base + slope1 + excess * slope2 / (BPS - kink);
}

拐点之后那段陡坡的作用不是赚钱,是保护流动性:利用率接近 100% 意味着存款人取不出钱,陡峭的利率让借款人迅速感到疼,从而还款把利用率压回去。

这是一个用价格做限流的设计。 它能不能奏效,取决于陡坡够不够陡,以及借款人有没有能力立刻还款。

第三,清算函数。 全章最需要小心的一段:

error NotLiquidatable(uint256 health);
error TooMuchDebt();

function liquidate(
    address user,
    uint256 repayAmount        // 清算人替他还多少债
) external {
    Account storage a = accounts[user];
    _accrueInterest(user);

    uint256 price = priceGuard.readPrice();          // T21 的四个检查在这里面
    uint256 health = healthFactor(a.collateral, price, liquidationLtv, a.debt);
    if (health >= BPS) revert NotLiquidatable(health);

    // 单次最多能还掉多少:closeFactor 典型区间 5000 bps 上下,可配置
    uint256 maxRepay = a.debt * closeFactor / BPS;
    if (repayAmount > maxRepay) revert TooMuchDebt();

    // 清算人拿走的抵押品 = 等值部分 + 奖励
    uint256 seized = repayAmount * BPS / price;
    seized = seized * (BPS + liquidationBonus) / BPS;

    // 抵押品不够了:这就是坏账的诞生现场
    if (seized > a.collateral) {
        seized = a.collateral;
        // 剩余债务无人偿还,必须记账,不能悄悄抹掉
        _recordBadDebt(user, a.debt - repayAmount);
    }

    a.debt -= repayAmount;
    a.collateral -= seized;

    _pullDebtToken(msg.sender, repayAmount);
    _pushCollateral(msg.sender, seized);
}

最后那个 if 分支就是坏账的诞生现场。它必须被显式写出来并记账

漏掉它的后果比你想的严重:如果抵押品不够时函数直接 revert,那么这个仓位永远无法被清算——它会一直挂在协议里,债务持续累积利息,而抵押品永远不够。坏账从一个已知的数字,变成一个不断增长的黑洞。

一条通用原则:坏账不可避免,但必须是可见的。 能被记账的损失,可以用储备金覆盖、可以按比例分摊给存款人、可以公开披露;藏起来的损失,会在某个人提款失败的那一刻集中爆发。

它叫什么

Collateral Factor / LTV抵押率

一单位抵押品最多能借出的比例。

它和清算阈值是两个数:抵押率决定开仓上限,清算阈值决定平仓触发点。两者之间的空隙是用户自救的唯一窗口,把它压到零等于让每个仓位一开出来就贴着清算线。

Health Factor健康度

按清算阈值折算后的抵押品价值除以债务。

大于 1 安全,小于 1 可被清算。它是一个比值而不是金额,所以同样的健康度下,仓位越大,清算失败的后果越严重。监控时两个维度都要看。

Utilization / Kink利用率 / 拐点

借出总额占存入总额的比例,以及利率曲线陡然变化的那个位置。

拐点之后的陡坡是一个流动性保护装置:它用价格逼迫借款人还款,从而保证存款人能取出钱。判断一条利率曲线好不好,不看它平缓段的形状,看它在利用率逼近 100% 时够不够狠。

Liquidation Bonus清算奖励

清算人能以折扣价拿走抵押品的那部分。

它是唯一让清算发生的理由。设低了没人来,设高了用户被过度惩罚,而且会诱发在小幅波动时就出手的激进清算。它本质上是在为「清算速度」定价。

Close Factor单次清算上限

一次清算最多能还掉仓位债务的多少比例。

它是「保护用户」和「保护协议」之间的旋钮。小的上限对用户温和,但清算人利润变薄、响应变慢;在极端行情下,很多实现会允许临时放宽它。

Bad Debt坏账

清算完成后仍未被覆盖的债务。

它的危险不在于金额本身,而在于它是否被记账。可见的坏账可以被储备金吸收或按比例分摊;不可见的坏账会变成一场挤兑——所有人都想成为最后一个取不出钱之前把钱取走的人。

动手

动手实现一个最小借贷池与清算函数,构造一次价格暴跌,验证清算能否及时完成Solidity + 任意合约测试框架0 元,全程本地网络

实现最小借贷池。 一种抵押品、一种借出资产,五个函数:supplyborrowrepaywithdrawliquidate

利息用「指数累计」而不是逐笔记账:维护一个全局的累计指数,每次操作时按时间推进它,用户的债务存成「本金除以开仓时的指数」。这样任意数量的用户,利息计算都是 O(1)。

价格源用 T21 的 PriceGuard,四个检查一个都不要去掉。

第一组测试:数值正确性。

  • 抵押 100 单位、价格 100、抵押率 7500,最多能借 7500,借 7501 必须失败。
  • 时间前进 30 天,债务按利率准确增长(手算一遍对照)。
  • 健康度公式在边界值上行为正确:恰好等于 1 时不可清算,小于 1 才可以。

边界那一条很重要。「大于等于」还是「大于」,决定了一批贴线仓位的命运。

第二组测试:构造一次价格暴跌。

写一个能控制价格的桩预言机,然后模拟一个下跌序列:

区块 0    价格 100,仓位健康度 1.33
区块 1    价格 92
区块 2    价格 85    ← 此时跌破清算线
区块 3    价格 79
区块 4    价格 74
区块 5    价格 70

关键在于:不要在区块 2 立刻执行清算。 分别在区块 3、4、5 才执行,记录每种情况下:

清算发生在清算人拿走用户剩下协议坏账
区块 3(延迟 1 块)
区块 4(延迟 2 块)
区块 5(延迟 3 块)

找出坏账从哪一行开始出现。 这一行就是你这套参数能承受的最大延迟。

第三组测试:清算人不来的情况。

把仓位规模缩小到一个很小的值,重新跑一遍。算一下清算人的净利润:

清算人净利 = 被清算金额 * 清算奖励 - Gas 成本 - 变现抵押品的滑点

当这个值小于等于 0 时,理性的清算人不会出手

算出那个临界仓位规模。 小于它的仓位在你的协议里是「清算不动」的,它们会变成粉尘坏账慢慢累积。

这个数字随 Gas 价格变化。把 Gas 价格乘以 10 再算一次,看临界规模变成多少——这解释了为什么拥堵时坏账集中出现

量化练习:算出你这套参数的失效边界。

把前两步的结果合成一张二维表。横轴是清算延迟(区块数),纵轴是这段时间内的跌幅:

           延迟 1 块   延迟 3 块   延迟 10 块
跌 5%        安全        安全        安全
跌 15%       安全        安全        坏账
跌 30%       安全        坏账        坏账
跌 50%       坏账        坏账        坏账

自己跑出真实的表格(分界线的位置由你的抵押率、清算阈值和奖励决定)。

然后回答三个问题:

  1. 你的资产在历史上出现过多大的单小时跌幅?
  2. 在那种行情下,链上 Gas 通常是什么水平,清算延迟会拉长到多少?
  3. 这两个数字落在你表格的哪一格?

如果落在「坏账」区,你的参数需要改——而且要改的多半不是利率曲线。

全程本地网络,不接任何真实资金。 真实资金只出现在毕业项目,并且必须先通过第五阶段的安全验收。

AI Lab

AI Lab让 AI 设计利率曲线,自己模拟资金利用率从 50% 冲到 99% 时会发生什么Level 2 · AI Copilot

分三步问:

第一步:
为一个借贷池设计双斜率利率曲线。给出参数、公式与 Solidity 实现,
说明拐点选在哪里、为什么,以及拐点之后的斜率要多陡才有用。

第二步:
假设利用率从 50% 在两小时内升到 99%。逐步模拟这个过程:
每一步的借款利率、存款利率、借款人的还款激励、存款人的存入激励。
说明在哪一个利用率水平上,市场力量会开始把它推回去。

第三步:
如果利用率达到 100% 并停在那里,会发生什么?
列出存款人、借款人、协议三方各自面临的情况,
以及协议可以采取的干预手段和每种手段的代价。

第二步是这个 Lab 的价值所在。设计曲线很容易,说清楚它为什么能自我修复很难。 追问到底:利率涨到某个水平,借款人真的会还款吗?如果他的钱都在一个不能立刻退出的仓位里呢?如果所有借款人同时想还款,他们买入还款资产的行为会不会又推高价格?

第三步经常暴露一个模型没想清楚的点:利用率 100% 时,存款人取不出钱,而高利率对他们毫无意义——账面收益再高,也换不成可支配的资产。这个状态如果持续,会直接演变成挤兑。

拿到答案后,自己写一段脚本把它的曲线跑一遍,用实际数字画出来。不要只看公式漂不漂亮。

AI 说完之后,你必须自己验证

  • 把它的曲线代入 50%、80%、95%、99%、100% 五个点,自己算出借款利率,和它给的数字是否一致
  • 在利用率 99% 时,它的曲线给出的利率是否高到足以逼借款人立刻还款——只是「比较高」是不够的
  • 存款利率有没有扣掉协议留存的那一部分,还是直接等于借款利率
  • 它的曲线在拐点处是否连续,代入拐点值从两边算,结果应当相同
  • 它有没有指出利用率达到 100% 时存款人无法提取,以及这时利率还能不能继续上升
  • 它提到的任何具体参数,你是否当成「可配置的起点」而不是事实

真实案例

拥堵时清算被挤出区块2020 年至今的多次剧烈行情

价格暴跌时,所有人同时在交易,Gas 价格飙升几十倍。清算交易需要和其他所有交易竞价,而它的利润是固定的那几个百分点。

结果是:清算人在最需要他们的时刻,反而算不过账。 利润小于 Gas 成本的清算干脆不做,于是仓位继续恶化,等到 Gas 回落时缺口已经形成。

这个案例说明清算延迟不是一个常数,它和市场压力正相关——恰恰在压力最大时最长。做参数设计时,不能用平静时期的延迟数据。

拍卖无人竞价,以极低价成交多起

一些协议用拍卖方式处理清算。在极端行情或网络问题下,拍卖开始后没有人出价,价格一路降到接近零,最终被一个几乎不付出代价的参与者拿走。

抵押品没了,债务没还上,缺口全额变成坏账。

教训有两条:拍卖必须有底价或价格下限;以及更根本的一条——任何依赖「一定会有人参与」的机制,都要设计好没有人参与时的行为

利用率打到 100%,存款人取不出钱持续存在的风险

某个资产被大量借出,利用率接近 100%。此时存款人发起提取会直接失败,因为池子里根本没有余额。

利率会飙升,理论上会逼借款人还款。但如果借款人是把钱投进了一个不能立刻退出的地方,短期内他还不了——高利率只是让他的债务增长得更快。

这就是拐点后那段陡坡真正要解决的问题,也是它经常不够用的原因。利率是一个滞后的调节手段,它调不动已经被锁死的资金。

粉尘仓位的长期累积所有借贷协议

大量很小的仓位跌破清算线,但每一个的清算利润都低于 Gas 成本,于是没有人清算。

它们不会立刻造成问题,但会持续累积利息,债务越滚越大,抵押品永远不够。若干个月之后,这些粉尘仓位加起来变成一笔不小的坏账。

常见的应对是设置最小借款额,以及为小额仓位提供批量清算入口,把多个仓位打包在一笔交易里处理,摊薄 Gas。

改一个变量

如果清算奖励从 5% 提到 15%

清算响应会明显变快,小仓位也重新变得值得清算,坏账风险下降。

代价有两层。表面一层是用户损失变大:被清算时多付了 10%。更深一层是清算变得激进——奖励足够高时,清算人会尽一切办法让更多仓位跌破线,包括在市场上主动打压价格。一个本来只是「事后处理」的角色,变成了有动机制造事件的参与者。

任何足够高的奖励,都会改变被奖励者的行为方式。 这是机制设计里反复出现的一条。

如果抵押率从 75% 提到 90%

资金效率大幅提升,协议对借款人的吸引力上升,借款量和收入都会增长。

同时你的时间预算从 25% 砍到 10%。用本章那张二维表重算一遍,你会发现大量原本落在安全区的格子跑到了坏账区

这就是为什么高抵押率通常只给波动极小、流动性极深的资产。抵押率的上限不是一个风险偏好问题,它由这个资产的波动率和清算延迟共同决定——可以直接算出来。

如果换到一条出块 0.4 秒但经常拥堵的链

理论上清算延迟可以缩短几十倍。

但要小心两件事。一是链上拥堵时,快出块反而放大了竞争:同样的机会在更短时间内被更多人争抢,失败重试的开销占比更高。二是所有按区块数配置的参数都要重算——原本「10 个区块内完成清算」是两分钟,现在是四秒,这个假设在拥堵时未必还成立。

结论和 T21 一样:换链时,所有和时间有关的参数都必须重新推导,不能复制。

如果把单次清算上限从 50% 改成 100%

清算人一次就能处理完整个仓位,利润翻倍,响应更快,协议更安全。

用户的体验会急剧变差:一次短暂的价格插针,可能让他失去全部抵押品,而价格几分钟后就恢复了。

多数实现的做法是分档:正常情况下限制在一半左右,健康度跌破某个更低的水平(说明缓冲已经快用完了)时允许全额清算。这样既保护了用户的正常波动,又保留了极端情况下的处置能力。

带走的问题

5
谁在支付?

谁在支付?借款人支付利息,其中一部分给存款人,一部分留给协议。被清算的用户额外支付清算奖励。看清这三笔钱的流向,就看清了这个协议的商业模式。

8
谁提供流动性?

谁提供流动性?存款人。但这一章加了一个新维度:清算人也在提供一种流动性——他们必须有能力立刻买走抵押品。抵押品的市场深度不够,清算就无法完成,这和 T21 的结论是同一件事。

9
谁承担风险?

谁承担风险?坏账最终落在协议储备和存款人身上,而不是清算人。清算人只在有利可图时出现,他们没有任何义务在行情最糟时提供服务。任何依赖他们的设计都必须为「他们不来」做好准备。

10
激励是什么?

激励是什么?整个清算体系只由一个数字驱动:清算奖励减去成本。如果你想知道清算会不会发生,不要看文档怎么写,算一下清算人这一笔能赚多少。

本章自测

一句话带走

借贷协议的安全边界是清算速度,不是利率模型的优雅程度。

做完这一章的动手环节了?勾上它查看全部进度

本页目录