做DeFi合约开发这几年,接触过不少从零搭建借贷协议的团队,也审计过十余个借贷类项目。一个很普遍的现象是:很多团队能快速搭出存、借、还的基础流程,但一到清算逻辑就容易出问题——要么抵押率计算精度出错,要么清算条件触发不对,要么留下了可被利用的安全漏洞。
借贷协议的本质是风控,而清算机制是整条风控线的最后一道闸门。清算逻辑写不好,轻则出现坏账,重则被攻击者利用直接掏空合约。这篇文章我们从最基础的超额抵押模型讲起,用Solidity手写一个可运行的极简借贷合约,重点拆解清算逻辑的实现细节,再结合审计经验讲清楚常见的安全坑点。
一、超额抵押:借贷协议的风控基石
和传统信用借贷不同,链上借贷没有身份和征信体系,全部依赖超额抵押来控制风险。用户存入抵押品后,只能借出低于抵押品价值的资产,一旦价格下跌导致抵押率不足,就会触发清算。
1.1 四个核心风控参数
理解借贷协议,先搞懂四个最核心的参数,所有清算逻辑都是围绕它们展开的:
- LTV(贷款价值比):用户最大可借款额度与抵押品价值的比例。比如LTV=75%,意味着存入价值1000美元的ETH,最多只能借出750美元的稳定币。这个值决定了资金利用率,越高资金效率越高,安全缓冲越小。
- 清算阈值:触发清算的临界抵押率。比如清算阈值=80%,当用户债务价值 / 抵押品价值 ≥ 80% 时,仓位就进入可清算状态。
- 清算折扣(清算激励):清算人替用户偿还债务后,可以以低于市场价的比例兑换抵押品,相当于清算的收益。比如5%的折扣,清算人每偿还100美元债务,可以拿走价值105美元的抵押品。
- 闭仓因子:单次清算最多可偿还的债务比例,通常为50%。避免一次性清算用户全部仓位,给用户留下补仓空间,也减少大额清算对市场的冲击。
这里有个最基础的设计原则:清算阈值必须大于LTV。两者的差值就是安全缓冲带——用户借到上限时抵押率是75%,跌到80%才触发清算,中间有5%的价格波动空间,不会因为微小的价格抖动就误清算。
1.2 清算触发的计算公式
清算的核心判断公式非常简单,但也是新手最容易写反的地方:
抵押率 = 用户债务总价值 / 用户抵押品总价值 × 100%
当 抵押率 ≥ 清算阈值 时,仓位可被清算
审计过的项目里,至少有三成的初级开发者会把分子分母写反,用抵押品除以债务,导致清算条件完全失效。
二、合约整体架构设计
我们实现一个极简的单抵押、单借贷币模型:抵押品为WETH,借款币为USDC,均遵循ERC20标准。暂不引入复杂的生息代币(cToken),利息采用按块计息模式,先把核心逻辑跑通。Compound、Aave这类主流协议,本质上也是在这个模型基础上做了多层扩展。
2.1 合约基础依赖
采用Solidity 0.8.20版本,自带整数溢出检查;引入OpenZeppelin的ERC20接口和SafeERC20库,处理代币转账的异常情况。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract SimpleLending {
using SafeERC20 for IERC20;
// 基础代币地址
IERC20 public immutable collateralToken; // 抵押品 WETH
IERC20 public immutable borrowToken; // 借款币 USDC
// 风控参数(生产环境建议通过治理修改)
uint256 public constant LTV = 75; // 贷款价值比 75%
uint256 public constant LIQUIDATION_THRESHOLD = 80; // 清算阈值 80%
uint256 public constant LIQUIDATION_BONUS = 5; // 清算折扣 5%
uint256 public constant CLOSE_FACTOR = 50; // 单次清算闭仓因子 50%
// 用户仓位状态
mapping(address => uint256) public collateralBalance; // 用户抵押品数量
mapping(address => uint256) public debtBalance; // 用户债务本金
mapping(address => uint256) public lastUpdateBlock; // 上次债务更新区块
// 利率配置:每区块利率,约年化4%
uint256 public constant INTEREST_RATE_PER_BLOCK = 1e12;
// 价格预言机,生产环境替换为Chainlink聚合器
address public priceOracle;
constructor(address _collateral, address _borrow) {
collateralToken = IERC20(_collateral);
borrowToken = IERC20(_borrow);
}
2.2 内部工具函数
两个最基础的内部函数:债务计息和价格获取。所有涉及用户债务变动的操作,都要先更新利息,再执行业务逻辑。
// 获取抵押品美元价格,精度1e8(对齐Chainlink报价精度)
function getCollateralPrice() internal view returns (uint256) {
// 测试环境模拟价格,生产环境调用 AggregatorV3Interface
return 2000e8; // 1 ETH = 2000 USDC
}
// 计算用户当前总债务(本金 + 应计利息)
function getUserTotalDebt(address user) internal view returns (uint256) {
uint256 principal = debtBalance[user];
if (principal == 0) return 0;
uint256 blocksPassed = block.number - lastUpdateBlock[user];
uint256 interest = principal * INTEREST_RATE_PER_BLOCK * blocksPassed / 1e18;
return principal + interest;
}
// 更新用户债务,将利息累计入本金
function _updateDebt(address user) internal {
uint256 totalDebt = getUserTotalDebt(user);
debtBalance[user] = totalDebt;
lastUpdateBlock[user] = block.number;
}
这里有个很容易忽略的细节:利息计算必须遵循先乘后除的原则。本金、利率、区块数全部相乘后,再除以精度基数。如果先做除法再做乘法,小数部分会被直接截断,导致利息计算偏少,累积下来就是系统的坏账缺口。
三、核心业务逻辑实现
3.1 存款与取款
存款逻辑最简单:用户转入抵押品,合约更新余额。取款则需要做风控校验,确保取出后剩余抵押品依然能覆盖当前债务。
// 存入抵押品
function deposit(uint256 amount) external {
require(amount > 0, "Amount cannot be zero");
_updateDebt(msg.sender);
collateralToken.safeTransferFrom(msg.sender, address(this), amount);
collateralBalance[msg.sender] += amount;
}
// 取出抵押品
function withdraw(uint256 amount) external {
require(amount > 0, "Amount cannot be zero");
require(collateralBalance[msg.sender] >= amount, "Insufficient collateral");
_updateDebt(msg.sender);
// 校验取出后剩余抵押品是否满足LTV要求
uint256 remainingCollateral = collateralBalance[msg.sender] - amount;
uint256 collateralValue = remainingCollateral * getCollateralPrice() / 1e8;
uint256 maxBorrowValue = collateralValue * LTV / 100;
require(debtBalance[msg.sender] <= maxBorrowValue, "Withdraw exceeds LTV limit");
collateralBalance[msg.sender] -= amount;
collateralToken.safeTransfer(msg.sender, amount);
}
很多新手写取款函数时会漏掉_updateDebt,用旧的本金数据做校验。如果用户借款后产生了利息,实际债务已经高于本金,就会出现超额取款的漏洞。
3.2 借款与还款
借款的核心是校验抵押率不超过LTV,还款则是冲减用户债务本金。
// 借款
function borrow(uint256 amount) external {
require(amount > 0, "Amount cannot be zero");
_updateDebt(msg.sender);
// 计算抵押品总价值与最大可借额度
uint256 collateralValue = collateralBalance[msg.sender] * getCollateralPrice() / 1e8;
uint256 maxBorrow = collateralValue * LTV / 100;
uint256 newDebt = debtBalance[msg.sender] + amount;
require(newDebt <= maxBorrow, "Exceed borrow limit");
debtBalance[msg.sender] = newDebt;
borrowToken.safeTransfer(msg.sender, amount);
}
// 还款
function repay(uint256 amount) external {
require(amount > 0, "Amount cannot be zero");
_updateDebt(msg.sender);
require(amount <= debtBalance[msg.sender], "Repay exceeds total debt");
borrowToken.safeTransferFrom(msg.sender, address(this), amount);
debtBalance[msg.sender] -= amount;
}
3.3 清算函数:核心中的核心
清算是借贷协议最容易出bug的模块,也是审计的重中之重。完整的清算流程分为五步:校验可清算状态、计算最大可清算额、折算抵押品数量、更新状态、执行转账。
// 清算资不抵债的仓位
function liquidate(address user, uint256 repayAmount) external {
require(user != msg.sender, "Cannot liquidate yourself");
_updateDebt(user);
// 1. 校验目标仓位是否达到清算阈值
uint256 userDebt = debtBalance[user];
uint256 collateralValue = collateralBalance[user] * getCollateralPrice() / 1e8;
uint256 currentRatio = userDebt * 100 / collateralValue;
require(currentRatio >= LIQUIDATION_THRESHOLD, "Position not liquidatable");
// 2. 计算本次最大可偿还金额(受闭仓因子限制)
uint256 maxRepay = userDebt * CLOSE_FACTOR / 100;
uint256 actualRepay = repayAmount > maxRepay ? maxRepay : repayAmount;
require(actualRepay > 0, "Repay amount too small");
// 3. 计算清算人可获得的抵押品数量(含清算折扣)
uint256 price = getCollateralPrice();
uint256 collateralAmount = actualRepay * 1e8 / price * (100 + LIQUIDATION_BONUS) / 100;
require(collateralBalance[user] >= collateralAmount, "Insufficient collateral");
// 4. 更新状态(先改状态,后转账,遵循CEI模式)
debtBalance[user] -= actualRepay;
collateralBalance[user] -= collateralAmount;
// 5. 执行代币转账
borrowToken.safeTransferFrom(msg.sender, address(this), actualRepay);
collateralToken.safeTransfer(msg.sender, collateralAmount);
}
}
这段代码里有三个非常关键的设计细节,也是最容易踩坑的地方:
第一,清算折扣的计算方向。清算人偿还债务后,是以折扣价买入抵押品,相同的资金能拿到更多代币,所以是乘以(100 + 折扣比例)。很多开发者会在这里写反,写成减法,导致清算人拿到的抵押品比市场价还少,完全没有清算动力。
第二,精度对齐。价格是1e8精度,USDC是6位小数,WETH是18位小数,计算时必须统一单位。上面的代码中用1e8和价格精度对齐,再做比例换算。单位错配是隐蔽bug的重灾区,很多测试用例因为用了整数数值测不出来,上线后才发现计算偏差。
第三,CEI模式(校验-效果-交互)。所有状态更新全部放在转账之前完成。哪怕后续的代币转账触发了外部回调,状态变量已经修改完毕,不会被重入攻击反复利用。这是Solidity开发的铁则,借贷合约因为涉及双向转账,尤其需要严格遵守。
四、清算机制的常见踩坑复盘
审计过的借贷项目里,一半以上的问题都出在清算模块。这里总结几个最高频的错误,很多有经验的开发者也会不小心踩中。
-
运算顺序错误导致精度丢失
整数运算先除后乘,会直接截断小数部分。比如小额清算时,先除以价格再乘折扣,可能算出抵押品数量为0,导致清算人白还债却拿不到抵押品;反过来也可能出现计算偏大,超额拿走抵押品的情况。 -
清算阈值与LTV参数倒挂
有些项目为了提高资金利用率,把LTV设成80%,清算阈值设成78%。用户借满额度的瞬间就满足清算条件,完全不符合业务逻辑。正常的设计必须保证清算阈值高于LTV,留出足够的价格缓冲空间。 -
遗漏闭仓因子,支持全额清算
没有闭仓因子限制,清算人可以一次性清空用户全部仓位。遇到极端行情时,用户没有补仓反应时间,大量抵押品被抛出又会进一步砸盘,形成死亡螺旋。主流协议普遍采用50%的闭仓因子,兼顾风控和用户体验。 -
清算折扣设置过高
折扣超过15%之后,清算收益会远高于正常水平,容易吸引MEV机器人争抢清算,甚至出现恶意操纵价格触发清算的情况。行业常规的清算折扣一般在5%-10%之间。 -
价格更新滞后
如果预言机喂价有延迟,或者使用了AMM现货价格,很容易出现价格已经暴跌但系统还没更新,该清算的仓位没触发,最终形成坏账。
五、合约安全审计核心要点
借贷协议一直是DeFi安全事故的重灾区,审计时以下几个模块是必查的高危点。
5.1 重入攻击风险
所有涉及外部代币转账的函数,都必须验证是否遵循CEI模式。清算函数同时有转入和转出操作,是重入攻击的高发区。如果先转账后更新状态,攻击者可以在转账回调中重复发起清算,反复套取抵押品。
另外必须使用SafeERC20库处理转账。部分非标ERC20代币转账失败不会抛出异常,只返回false,如果不检查返回值,会出现“状态扣减了但钱没到账”的情况。
5.2 预言机操纵风险
这是借贷协议最致命的攻击面。如果直接使用Uniswap等AMM的现货价格喂价,攻击者可以通过闪电贷瞬间砸盘或拉盘,人为触发清算,以极低价格套走合约内的抵押品。
合规的喂价方案必须使用时间加权平均价格(TWAP)或Chainlink去中心化预言机,同时加入价格波动熔断机制:短时间内价格波动超过阈值时,暂停清算和借款功能。
5.3 边界条件与异常场景
审计时必须覆盖所有边界场景:
- 债务刚好等于清算阈值时,能否正常触发清算
- 偿还金额极小,计算出的抵押品为0时,会不会绕过校验
- 清算后债务清零,剩余的零星抵押品能否正常取出
- 抵押品价格为0时,会不会出现除以零的回退
- 多笔清算同时处理同一仓位,会不会出现状态竞争
5.4 权限与治理风险
如果协议保留了管理员修改LTV、清算阈值、预言机地址的权限,必须配套时间锁和多签机制。管理员权限一旦泄露,恶意修改参数可以直接掏空合约。生产环境的核心参数,建议全部通过DAO治理投票修改。
六、生产环境的扩展方向
本文实现的是极简原型,真正上线的借贷协议还需要做大量工程化扩展:
- 引入生息凭证代币(cToken),用户存款即可获得可转让、可组合的计息资产
- 动态利率模型,根据资金池利用率自动调节借款利率,提升资金使用效率
- 多抵押品支持,为不同风险等级的资产设置独立的LTV和清算参数
- 大额清算拍卖机制,通过竞价减少清算对市场的冲击
- 坏账准备金体系,应对极端行情下的清算失败场景
写在最后
借贷协议的基础逻辑看起来不复杂,但真正做到安全、稳定、可扩展,需要打磨非常多的细节。尤其是清算模块,一边连着用户资产安全,一边连着系统坏账风险,一行代码的逻辑偏差,就可能造成数百万美元的损失。
写合约不难,写安全的合约很难。多站在攻击者的视角审视每一行代码,多做边界测试和模糊测试,才能把风险降到最低。
本文详解DeFi超额抵押借贷协议的核心风控逻辑,聚焦清算触发条件、LTV与清算阈值关系、清算折扣计算及闭仓因子设计。基于Solidity实现极简存借清全流程,强调CEI模式、精度对齐、整数运算顺序等关键实现细节,并系统梳理重入攻击、预言机操纵、边界异常、权限治理四大安全审计要点,覆盖DeFi借贷合约高危风险点。

353

被折叠的 条评论
为什么被折叠?



