Appearance
L3-2: Lending App(借贷应用)
1. 问题
DeFi 借贷协议的核心挑战是:如何在去中心化、无 KYC 的环境中安全地借出资金?传统银行依赖信用评分和抵押物托管来管理风险,但智能合约无法进行链下信用评估。因此,DeFi 借贷必须采用超额抵押模型——借款人需要存入比借款价值更高的抵押品。但这引发了一连串设计问题:如何确定抵押率?利率如何动态调整?当抵押品价值下跌时,如何保护存款人的资金安全?
一个完整的去中心化借贷协议需要精确地解决上述所有问题,同时确保合约的逻辑正确性和资金安全。Aave 和 Compound 等主流协议验证了这套模型的可行性,它们共同管理着数百亿美元的 TVL。
2. 原因
借贷是 DeFi 三大支柱(交易、借贷、衍生品)中 TVL 最高的赛道。理解借贷协议的内部运作机制——从存款生息的 shares 模型,到健康因子的计算,再到清算逻辑——是 DeFi 开发者的核心能力。这套知识直接适用于评估和构建借贷协议、杠杆策略和清算机器人。
超额抵押借贷的独特之处在于它创造了一个无需信任的信用市场:借款人不依赖信用评分,而是依赖链上抵押品的透明价值。存款人通过承担借款人的违约风险来赚取利息(在清算发生时,存款人的资金由清算人通过买入抵押品来保护)。这套自执行的清算机制是 DeFi 借贷区别于传统金融的核心创新。
3. 方案
LendingApp.sol 实现了简化的借贷协议,包含存款、借款、还款和清算四大核心功能。
存款机制:用户调用 deposit() 将 ERC20 代币存入合约。存款用于为借款提供流动性池。存入的代币总量记录在 totalDeposited 中。
借款机制:用户调用 borrow() 借出代币。借款前必须检查健康因子——抵押价值与借款价值的比率。合约使用 COLLATERAL_RATIO = 150%,意味着你需要存入 150 美元的抵押品才能借出 100 美元。健康因子通过 _healthFactor() 计算:hf = (collateralValue * 100) / debtValue,其中 debtValue = borrowed * borrowIndex。
利率模型:使用简化版线性利率累积器。borrowIndex 从 1e18(1.0)开始,随时间增长。_accrueInterest() 在每次状态变更前被调用,累积经过时间的利息:年化利率 5%,按秒线性计算。借款人的实际债务 = borrowed * borrowIndex / borrowerIndexAtBorrowTime。
清算机制:当健康因子低于 COLLATERAL_RATIO(150%)时,任何人可以调用 liquidate() 来偿还借款人的债务,并获得抵押品 + 10% 的清算奖励。清算人(liquidator)支付的债务金额等于借款人的 borrowed 总量,获得的抵押品 = debtValue * (100 + 10) / 100,折算回存入代币。
参考源文件:src/level3/LendingApp.sol
4. 遭遇的陷阱
- 利率累积精度丢失:在
_accrueInterest()中使用整数除法,利息因子interestFactor在时间段较短时可能被截断为 0 - 清算时数值溢出:
collateralToSeize的计算使用了debtValue * (RATIO_PRECISION + LIQUIDATION_BONUS) / RATIO_PRECISION,但debtValue本身可能已经因为借款指数膨胀而成为很大的数值 - 取款后的健康因子检查:
withdraw()函数需要在取款前和假设取款后分别验证健康因子,遗漏检查可能导致取款后仓位立即不健康 - 清算人支付的双重收取:
liquidate()中清算人需要transferFrom支付债务,同时合约还要transfer返还抵押品,如果transferFrom失败而抵押品状态已被修改,会导致状态不一致 - 借款指数未初始化的用户:新用户借款时
borrowIndex[user]为 0,需要特殊处理以避免除以零错误
5. 陷阱的原因
利率累积精度丢失是最常见的问题。在 Solidity 中,整数除法会向零截断。当 INTEREST_RATE * timeElapsed 的值小于 365 days * 100 时,interestFactor 的计算结果为 0,导致利率完全无法累积。这是典型的 EVM 精度问题——因为 Solidity 没有浮点数,所有比率计算都需要放大精度单位后再除法。
在 LendingApp.sol 中,_accrueInterest() 的利率公式为:
solidity
interestFactor = (INTEREST_RATE * timeElapsed * 1e18) / (365 days * 100);
当 timeElapsed 较小时(例如一个区块时间 12 秒),5 * 12 * 1e18 / (31536000 * 100) = 一个很小的数。如果这个值小于 1(在 1e18 精度下),除法结果就是 0,随后的 borrowIndex = borrowIndex + (borrowIndex * 0) / 1e18 也不会改变指数。虽然单个区块的舍入看似无关紧要,但累积效应可能导致实际利率远低于预期。
6. 如何解决陷阱
利率累积:在短期内(几分钟到几小时),0 利率的截断是可接受的,因为利率本身很小。对于需要更高精度的场景,可以增大中间精度(如使用 1e27 精度,即 RAY 单位),或使用指数累积公式 borrowIndex = borrowIndex * (1e18 + ratePerSecond * elapsed) / 1e18 来减少单次截断的影响。关键是在 _accrueInterest() 的 if (interestFactor > 0) 检查后,确保只有当利率确实产生了影响时才更新指数。
清算计算:在清算前先计算最终结果,再修改状态。使用 ReentrancyGuard(合约已继承)防止重入攻击。在 liquidate() 中,debtAmount 和 collateralSeized 在 transferFrom 之前计算,且 pos.borrowed 在转移代币前被归零,符合 CEI(Checks-Effects-Interactions)模式。
取款验证:在 withdraw() 中严格按照逻辑顺序:先 _accrueInterest(),再计算健康因子,再判断取款后的健康因子是否满足要求,最后才执行状态更新和代币转移。remainingCollateral == 0 && pos.borrowed > 0 的显式检查防止了有借款但零抵押品的情况。
7. 技术要点
| 技术点 | 说明 |
|---|---|
| 超额抵押率 | 150%(COLLATERAL_RATIO),存入 $150 借 $100 |
| 健康因子 | collateralValue * 100 / debtValue,低于 150% 可被清算 |
| 清算奖励 | 10%(LIQUIDATION_BONUS),清算人获抵押品 + 10% 溢价 |
| 利率模型 | 简化线性累积,5% 年化,按秒计算 |
| borrowIndex | 全局借款指数,初始 1e18(精度 18 位) |
| CEI 模式 | Checks-Effects-Interactions,状态更新在外部调用之前 |
| ReentrancyGuard | 所有公开函数使用 nonReentrant 修饰符 |
| 单一代币模型 | 存款和借款使用同一种 ERC20 代币(简化) |