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 - 债务 / (抵押品价值 * 清算阈值) ← 你能设计的部分
清算延迟 = 发现 + 决策 + 资金准备 + 打包上链 ← 你控制不了的部分左边那一项是市场的速度,右边那一项是你的设计。安全与否,就是这两个数字比大小。
大多数人把注意力放在右边——调参数、优化公式、证明利率曲线的连续性。但事故几乎全部来自左边,而左边由两个因素决定:波动有多剧烈,和清算有多慢。
于是有了这一章的那句话:借贷协议的安全边界是清算速度,不是利率模型的优雅程度。
利率模型决定协议赚多少钱。清算速度决定协议会不会死。
建立模型
先把清算延迟拆开,你才知道该优化哪一段:
- 价格下跌
- 仓位跌破阈值
- 有人发现
- 准备资金并发出交易
- 交易被打包
- 清算完成
| 阶段 | 典型耗时 | 谁能优化 | 怎么优化 |
|---|---|---|---|
| 价格进入链上 | 取决于预言机心跳 | 协议 | 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,那么这个仓位永远无法被清算——它会一直挂在协议里,债务持续累积利息,而抵押品永远不够。坏账从一个已知的数字,变成一个不断增长的黑洞。
一条通用原则:坏账不可避免,但必须是可见的。 能被记账的损失,可以用储备金覆盖、可以按比例分摊给存款人、可以公开披露;藏起来的损失,会在某个人提款失败的那一刻集中爆发。
它叫什么
一单位抵押品最多能借出的比例。
它和清算阈值是两个数:抵押率决定开仓上限,清算阈值决定平仓触发点。两者之间的空隙是用户自救的唯一窗口,把它压到零等于让每个仓位一开出来就贴着清算线。
按清算阈值折算后的抵押品价值除以债务。
大于 1 安全,小于 1 可被清算。它是一个比值而不是金额,所以同样的健康度下,仓位越大,清算失败的后果越严重。监控时两个维度都要看。
借出总额占存入总额的比例,以及利率曲线陡然变化的那个位置。
拐点之后的陡坡是一个流动性保护装置:它用价格逼迫借款人还款,从而保证存款人能取出钱。判断一条利率曲线好不好,不看它平缓段的形状,看它在利用率逼近 100% 时够不够狠。
清算人能以折扣价拿走抵押品的那部分。
它是唯一让清算发生的理由。设低了没人来,设高了用户被过度惩罚,而且会诱发在小幅波动时就出手的激进清算。它本质上是在为「清算速度」定价。
一次清算最多能还掉仓位债务的多少比例。
它是「保护用户」和「保护协议」之间的旋钮。小的上限对用户温和,但清算人利润变薄、响应变慢;在极端行情下,很多实现会允许临时放宽它。
清算完成后仍未被覆盖的债务。
它的危险不在于金额本身,而在于它是否被记账。可见的坏账可以被储备金吸收或按比例分摊;不可见的坏账会变成一场挤兑——所有人都想成为最后一个取不出钱之前把钱取走的人。
动手
实现最小借贷池。 一种抵押品、一种借出资产,五个函数:supply、borrow、repay、withdraw、liquidate。
利息用「指数累计」而不是逐笔记账:维护一个全局的累计指数,每次操作时按时间推进它,用户的债务存成「本金除以开仓时的指数」。这样任意数量的用户,利息计算都是 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% 坏账 坏账 坏账自己跑出真实的表格(分界线的位置由你的抵押率、清算阈值和奖励决定)。
然后回答三个问题:
- 你的资产在历史上出现过多大的单小时跌幅?
- 在那种行情下,链上 Gas 通常是什么水平,清算延迟会拉长到多少?
- 这两个数字落在你表格的哪一格?
如果落在「坏账」区,你的参数需要改——而且要改的多半不是利率曲线。
全程本地网络,不接任何真实资金。 真实资金只出现在毕业项目,并且必须先通过第五阶段的安全验收。
AI Lab
分三步问:
第一步:
为一个借贷池设计双斜率利率曲线。给出参数、公式与 Solidity 实现,
说明拐点选在哪里、为什么,以及拐点之后的斜率要多陡才有用。
第二步:
假设利用率从 50% 在两小时内升到 99%。逐步模拟这个过程:
每一步的借款利率、存款利率、借款人的还款激励、存款人的存入激励。
说明在哪一个利用率水平上,市场力量会开始把它推回去。
第三步:
如果利用率达到 100% 并停在那里,会发生什么?
列出存款人、借款人、协议三方各自面临的情况,
以及协议可以采取的干预手段和每种手段的代价。第二步是这个 Lab 的价值所在。设计曲线很容易,说清楚它为什么能自我修复很难。 追问到底:利率涨到某个水平,借款人真的会还款吗?如果他的钱都在一个不能立刻退出的仓位里呢?如果所有借款人同时想还款,他们买入还款资产的行为会不会又推高价格?
第三步经常暴露一个模型没想清楚的点:利用率 100% 时,存款人取不出钱,而高利率对他们毫无意义——账面收益再高,也换不成可支配的资产。这个状态如果持续,会直接演变成挤兑。
拿到答案后,自己写一段脚本把它的曲线跑一遍,用实际数字画出来。不要只看公式漂不漂亮。
AI 说完之后,你必须自己验证
- 把它的曲线代入 50%、80%、95%、99%、100% 五个点,自己算出借款利率,和它给的数字是否一致
- 在利用率 99% 时,它的曲线给出的利率是否高到足以逼借款人立刻还款——只是「比较高」是不够的
- 存款利率有没有扣掉协议留存的那一部分,还是直接等于借款利率
- 它的曲线在拐点处是否连续,代入拐点值从两边算,结果应当相同
- 它有没有指出利用率达到 100% 时存款人无法提取,以及这时利率还能不能继续上升
- 它提到的任何具体参数,你是否当成「可配置的起点」而不是事实
真实案例
价格暴跌时,所有人同时在交易,Gas 价格飙升几十倍。清算交易需要和其他所有交易竞价,而它的利润是固定的那几个百分点。
结果是:清算人在最需要他们的时刻,反而算不过账。 利润小于 Gas 成本的清算干脆不做,于是仓位继续恶化,等到 Gas 回落时缺口已经形成。
这个案例说明清算延迟不是一个常数,它和市场压力正相关——恰恰在压力最大时最长。做参数设计时,不能用平静时期的延迟数据。
一些协议用拍卖方式处理清算。在极端行情或网络问题下,拍卖开始后没有人出价,价格一路降到接近零,最终被一个几乎不付出代价的参与者拿走。
抵押品没了,债务没还上,缺口全额变成坏账。
教训有两条:拍卖必须有底价或价格下限;以及更根本的一条——任何依赖「一定会有人参与」的机制,都要设计好没有人参与时的行为。
某个资产被大量借出,利用率接近 100%。此时存款人发起提取会直接失败,因为池子里根本没有余额。
利率会飙升,理论上会逼借款人还款。但如果借款人是把钱投进了一个不能立刻退出的地方,短期内他还不了——高利率只是让他的债务增长得更快。
这就是拐点后那段陡坡真正要解决的问题,也是它经常不够用的原因。利率是一个滞后的调节手段,它调不动已经被锁死的资金。
大量很小的仓位跌破清算线,但每一个的清算利润都低于 Gas 成本,于是没有人清算。
它们不会立刻造成问题,但会持续累积利息,债务越滚越大,抵押品永远不够。若干个月之后,这些粉尘仓位加起来变成一笔不小的坏账。
常见的应对是设置最小借款额,以及为小额仓位提供批量清算入口,把多个仓位打包在一笔交易里处理,摊薄 Gas。
改一个变量
清算响应会明显变快,小仓位也重新变得值得清算,坏账风险下降。
代价有两层。表面一层是用户损失变大:被清算时多付了 10%。更深一层是清算变得激进——奖励足够高时,清算人会尽一切办法让更多仓位跌破线,包括在市场上主动打压价格。一个本来只是「事后处理」的角色,变成了有动机制造事件的参与者。
任何足够高的奖励,都会改变被奖励者的行为方式。 这是机制设计里反复出现的一条。
资金效率大幅提升,协议对借款人的吸引力上升,借款量和收入都会增长。
同时你的时间预算从 25% 砍到 10%。用本章那张二维表重算一遍,你会发现大量原本落在安全区的格子跑到了坏账区。
这就是为什么高抵押率通常只给波动极小、流动性极深的资产。抵押率的上限不是一个风险偏好问题,它由这个资产的波动率和清算延迟共同决定——可以直接算出来。
理论上清算延迟可以缩短几十倍。
但要小心两件事。一是链上拥堵时,快出块反而放大了竞争:同样的机会在更短时间内被更多人争抢,失败重试的开销占比更高。二是所有按区块数配置的参数都要重算——原本「10 个区块内完成清算」是两分钟,现在是四秒,这个假设在拥堵时未必还成立。
结论和 T21 一样:换链时,所有和时间有关的参数都必须重新推导,不能复制。
清算人一次就能处理完整个仓位,利润翻倍,响应更快,协议更安全。
用户的体验会急剧变差:一次短暂的价格插针,可能让他失去全部抵押品,而价格几分钟后就恢复了。
多数实现的做法是分档:正常情况下限制在一半左右,健康度跌破某个更低的水平(说明缓冲已经快用完了)时允许全额清算。这样既保护了用户的正常波动,又保留了极端情况下的处置能力。
带走的问题
谁在支付?借款人支付利息,其中一部分给存款人,一部分留给协议。被清算的用户额外支付清算奖励。看清这三笔钱的流向,就看清了这个协议的商业模式。
谁提供流动性?存款人。但这一章加了一个新维度:清算人也在提供一种流动性——他们必须有能力立刻买走抵押品。抵押品的市场深度不够,清算就无法完成,这和 T21 的结论是同一件事。
谁承担风险?坏账最终落在协议储备和存款人身上,而不是清算人。清算人只在有利可图时出现,他们没有任何义务在行情最糟时提供服务。任何依赖他们的设计都必须为「他们不来」做好准备。
激励是什么?整个清算体系只由一个数字驱动:清算奖励减去成本。如果你想知道清算会不会发生,不要看文档怎么写,算一下清算人这一笔能赚多少。
本章自测
因为坏账的产生条件是一个速度比较:在清算完成之前,价格跌掉的幅度超过了缓冲。
利率模型影响协议赚多少钱、资金利用率高不高、存款人愿不愿意来。它重要,但它不决定协议会不会在一次暴跌中破产。
一个精心设计的利率曲线配上过高的抵押率和缓慢的清算,仍然会产生坏账;一个粗糙的利率曲线配上保守的抵押率和高效的清算,可以安然度过剧烈行情。
抵押率决定用户能借多少(开仓上限),清算阈值决定什么时候被清算(平仓触发点)。清算阈值必须更高。
两者之间的差距是用户的自救空间:价格开始下跌时,他有机会还一部分款或追加抵押品,而不是一跌就被清算。
如果把两者设成同一个数,每个刚开出来的仓位都贴在清算线上,任何微小的价格波动或利息累积都会立刻触发清算。
三种情况:利润小于成本(小仓位,或 Gas 飙升);抵押品难以变现(深度不足,卖出的滑点吃掉了奖励);竞争失败(抢不过别人,反复白付手续费)。
应对手段:设最小借款额避免粉尘仓位;提供批量清算入口摊薄 Gas;奖励随时间递增让市场自己定价;允许清算人在同一笔交易里借入所需资金,消除资金准备这一环。
最重要的一条是心态上的:把「没有清算人出现」当成一个必然会发生的状态来设计,而不是一个异常。
不应该。直接 revert 会让这个仓位永远无法被清算,债务继续累积利息,缺口越来越大。
正确做法是:把抵押品全部交给清算人,把剩余债务显式记为坏账,并触发一个事件让外部能立刻察觉。
坏账不可避免,但必须可见。 能被记账的损失可以用储备金覆盖或按规则分摊;被藏起来的损失会在某个人提款失败时集中爆发,并演变成挤兑。
没有标准答案,检查你的推导里有没有这几项:
- 这个资产历史上的单小时最大跌幅,以及那种行情出现的频率。
- 那种行情下的实际清算延迟——用链上数据统计,不要用平静时期的值。
- 在该延迟内的预期跌幅,加上清算奖励占用的部分,就是你需要的缓冲下限。
- 这个资产的市场深度:清算人要把抵押品卖掉,滑点会不会吃掉奖励。
- 协议对这个资产的总敞口上限:即使参数都对,单一资产占比过高也会让局部问题变成系统问题。
第 2 和第 4 条是最多被跳过、也最能决定成败的两条。它们都要求你去链上取真实数据,而不是查一个推荐值。
一句话带走
借贷协议的安全边界是清算速度,不是利率模型的优雅程度。