Solidity手写DeFi借贷协议:从业务建模到部署全复盘

发布时间:2026/9/15 3:27:19
Solidity手写DeFi借贷协议:从业务建模到部署全复盘 做DeFi协议开发这两年我最常被问到的问题就是合约代码看起来能看懂但真让自己动手设计一个借贷协议却不知道从哪里下手。老实说最开始我也一样。后来我专门花了两个周末用 Solidity 从一个最小需求出发把 DeFi 借贷协议的整个链路完整走了一遍——业务模型、利率计算、抵押清算、测试部署全部打通。这篇文章就是这次实践的项目复盘我会把设计思路、数学原理、合约代码和部署测试里踩过的坑一条线讲清楚。如果你想自己写一个区块链协议或者想在面试中把项目模块拆清楚这份内容应该能帮你省不少时间。需要先说明本文里所有合约代码都是教学示例没有经过任何审计千万别拿真金白银的主网资产去跑跑测试网就够了。1. 项目定位与整体设计思路1.1 为什么选借贷协议作为切入点DeFi 里的产品形态很多去中心化交易所、稳定币协议、收益聚合器、衍生品平台。但我建议入门开发者第一站就写借贷协议原因是它麻雀虽小五脏俱全。一笔借贷业务里至少包含存款、借款、还款、清算四类核心动作背后还牵扯到利息模型、价格预言机、抵押率校验和资金安全边界。把这些环节串起来你对 DeFi 协议的整体运作方式就会有非常具体的体感而不是停留在白皮书概念层面。我们这个项目要做的是一个“最小可用”的借贷协议业务场景定义得尽量窄用户可以用 WETH 作为抵押品借出稳定币 USDC用户也可以直接存入 USDC作为借款池的流动性来源当抵押品价格下跌导致抵押率低于清算线时任何人都可以发起清算替借款用户偿还部分债务并拿走打折后的抵押品。整个协议只需要两个代币合约和一个核心借贷合约。这样做的好处是你不需要处理多资产兑换、滑点、无常损失这些 AMM 类问题但依然能完整体验“资金池 债务记账 风险控制”的闭环。等这个模型跑通了再往 Compound 或 Aave 那种多资产方向扩展会从容很多。从发散创新的角度我在设计时刻意把几个关键点做成了“可替换模块”利率模型单独成函数价格来源单独抽象清算折扣单独配置。后续想接 Chainlink 预言机、改成动态利率、或者引入治理参数投票都不需要推翻重建。1.2 技术选型Solidity版本、框架与依赖合约语言用 Solidity 0.8.19 及以上版本。这里多说一句0.8.0 之后编译器内置了整数溢出检查以前那种 require 写法配合 SafeMath 的日子已经过去了现在普通算术运算溢出会直接 revert这给我们去掉了很多隐藏风险。开发框架我选了 FoundryFoundry 是 Rust 写的forge test 跑测试速度非常快而且命令行工具 cast 可以直接读合约状态、发送交易调试体验比 Hardhat 脚本舒服不少。当然你用 Hardhat 也完全没问题核心合约代码是通用的只是测试脚本语法不同。依赖方面只引入 OpenZeppelin Contracts主要用它的 ERC20、ReentrancyGuard、SafeERC20。SafeERC20 很关键它可以兼容 USDT 这类返回值非标准的代币后面测试省了不少事。项目的目录结构大概是这样defi-lending/ ├── src/ │ ├── SimpleLending.sol │ ├── MockWETH.sol │ └── MockUSDC.sol ├── test/ │ └── SimpleLending.t.sol ├── script/ │ └── Deploy.s.sol ├── lib/ ├── foundry.toml └── remappings.txtsrc放合约源码test放测试script放部署脚本。合约逻辑全部集中在 SimpleLending.sol 里MockWETH 和 MockUSDC 是测试用的代币。1.3 模块划分与可扩展点虽然最终实现上我把主要逻辑写在了一个合约里但在脑内和文档里我始终把它分成四个概念模块模块职责对应实现抵押品管理记录用户抵押资产数量接收和释放抵押代币collateralAmount 字段 depositCollateral / withdrawCollateral债务账本记录借款本息使用债务份额计算累积利息debtShares 字段 borrowIndex 全局指数利率模型决定借款利息如何随时间累计interestRatePerSecond updateIndex清算引擎检查抵押率执行清算激励isLiquidatable liquidate这四个模块是可以独立替换的。比如后续想把固定利率升级成动态利用率利率只需要改interestRatePerSecond的生成方式不动债务账本结构想把固定价格换成链上预言机也只需要调整oraclePrice的赋值来源。这种模块化设计不是过度设计而是给协议留出一条平滑升级的路径。真正的 DeFi 协议几乎都是往这个方向演化的。2. 核心机制与数学原理2.1 从业务需求到数据模型写借贷协议的第一件事不是写代码而是确定数据怎么存。最直观的思路是每个用户记录两个数字抵押品数量、借款金额。但这里有个麻烦——借款金额会随时间变化因为利息一直在涨。如果你直接存一个债务余额那每过一秒你都要遍历所有用户去更新利息这在链上完全不可行。所以实际协议里普遍采用“份额 全局指数”的办法这也是 Compound 的核心思路。我们把用户的债务拆成两层用户持有的是“债务份额”这个份额基本不变全局有一个不断增长的“债务指数”borrowIndex代表每单位份额对应的最新债务金额。用户的当前债务就等于债务金额 债务份额 × borrowIndex / 1e271e27是我代码里的精度基数叫 RAY。理解不了就把它想象成一个放大镜为了让利率计算不丢精度。这个机制可以类比借书积分你在图书馆借书时会得到一个积分数积分不会每天变但图书馆的系统里有个全局汇率这个汇率每天都在涨最终你欠的书钱就是积分乘以汇率。这样图书馆只需要更新一个全局汇率不用天天去改每个借书人的账户。2.2 抵押率、清算线怎么定借贷协议必须保证借出去的钱能收回来抵押率就是这套机制的核心。抵押率的定义是抵押率 抵押品价值 / 借款金额比如项目设置初始抵押率为 150%意味着你抵押价值 2000 USDC 的资产时最多只能借 2000 / 1.5 ≈ 1333 USDC。这留了 33% 的安全缓冲防止价格轻微波动就把抵押品跌穿。清算线我设成 110%。当抵押品价值除以借款金额小于 110% 时任何人都可以对这笔借贷执行清算。执行清算的人会替借款人偿还部分债务同时按折扣价格拿走抵押品。折扣我默认是 5%意思是花 100 美元还债能拿走价值约 105 美元的抵押品利润空间足以覆盖手续费和滑点。这个差值为什么留这么大我们来算一个真实场景。用户抵押 1 个 WETH假设价格 2000 USDC按 150% 初始抵押率最多借出 1333 USDC。如果 WETH 价格跌到 1470 USDC此时抵押率就是 1470 / 1333 ≈ 110%进入可清算状态。如果初始抵押率和清算线离得太近极端行情下一笔交易从健康直接跌到穿仓清算人来不及操作协议就会产生坏账所以初始抵押率必须显著高于清算线。2.3 利息模型从固定利率到动态利率教学版本我用了固定年化利率 5%。这本来很简单但链上只有区块时间戳没有传统金融里的“日历日计息”概念所以要把年利率折算成单秒利率。我设定uint256 public interestRatePerSecond 1_584_436_926;这个数是用 RAY 精度表示的秒利率对应的年化大约是 5%。实际计算时每次操作前先看自上次更新到现在过了多少秒然后用复利公式累加一次borrowIndex_new borrowIndex × (1 秒利率 × 流逝秒数)严格来说这是单利近似的复利在合理参数下精度足够了。为什么不用直接幂运算链上算指数很贵而且容易溢出线性展开是最实用的做法。真实协议不会用固定利率而是根据资金利用率动态调整。资金利用率的定义是利用率 总借款金额 / 总存款金额利用率越高说明借款需求大、流动性紧张利率就应该上涨利用率低则利率下降鼓励借款。常见的模型是借款利率 基础利率 利用率 × 利率斜率比如基础利率 2%斜率 30%利用率 80% 时借款年化就是 2% 0.8×30% 26%。这个逻辑在代码里其实不难只要把interestRatePerSecond改成由utilization实时计算即可但为了让项目走通闭环我先用固定利率把整体流程跑通动态利率作为后续扩展点。3. 合约代码实现与解析3.1 工程初始化和依赖引入我用 Foundry 创建项目一条命令就搞定forge init defi-lending cd defi-lending forge install OpenZeppelin/openzeppelin-contracts然后配置 remappings让 import 能找到 OpenZeppelin 包openzeppelin/contracts/lib/openzeppelin-contracts/contracts/接下来写测试代币。为什么需要两个 Mock 代币因为我们在测试网上不一定有真实 WETH 和 USDC而且真实代币涉及主网余额不方便随意铸币。自己造一个测试代币可以随时增发完全可控。这里用 OpenZeppelin 的 ERC20 扩展// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import openzeppelin/contracts/token/ERC20/ERC20.sol; contract MockWETH is ERC20 { constructor() ERC20(Mock Wrapped Ether, MWETH) {} function mint(address to, uint256 amount) external { _mint(to, amount); } } contract MockUSDC is ERC20 { constructor() ERC20(Mock USD Coin, MUSDC) {} function mint(address to, uint256 amount) external { _mint(to, amount); } }注意两个代币的 decimals 我都保持默认 18没有去模拟真实 USDC 的 6 位小数。真实项目里 18 和 6 混用非常容易出错这属于 DeFi 开发的高频事故来源教学环境为了聚焦业务逻辑统一成 18 位是更明智的选择。3.2 LendingPool 核心合约实现核心合约叫 SimpleLending我把状态变量、用户结构体、利息更新、存款、借款、还款、清算一次都放进去。业务上有所简化但骨架是完整可运行的。先看状态定义contract SimpleLending is ReentrancyGuard { using SafeERC20 for IERC20; IERC20 public immutable collateralToken; IERC20 public immutable borrowToken; uint256 public constant RAY 1e27; uint256 public collateralizationRate 1500; // 150% uint256 public liquidationThreshold 1100; // 110% uint256 public oraclePrice 2000 * 1e18; // 1 WETH 2000 MUSDC测试值 uint256 public interestRatePerSecond 1_584_436_926; // 约5%年化 uint256 public lastInterestUpdated; uint256 public borrowIndex RAY; uint256 public totalDeposits; uint256 public totalDebtShares; struct UserInfo { uint256 collateralAmount; uint256 debtShares; uint256 depositAmount; } mapping(address UserInfo) public users; event Deposited(address indexed user, uint256 amount); event Withdrawn(address indexed user, uint256 amount); event Borrowed(address indexed user, uint256 amount); event Repaid(address indexed user, uint256 amount); event Liquidated(address indexed liquidator, address indexed borrower, uint256 repayAmount); }collateralizationRate存的是万分之几1500 代表 150%这样避免小数运算。oraclePrice是抵押品相对借出代币的价格我这里写死了 2000演示够用。生产环境肯定要接 Chainlink 或等价预言机这一点后面会再次强调。利息累计核心是updateIndex。这个函数必须在每次可能与债务交互的操作前调用它只更新一个全局值成本不高function updateIndex() public { uint256 elapsed block.timestamp - lastInterestUpdated; if (elapsed 0) return; uint256 interestFactor RAY interestRatePerSecond * elapsed; borrowIndex (borrowIndex * interestFactor) / RAY; lastInterestUpdated block.timestamp; }interestRatePerSecond * elapsed可能会变得很大但在我们设定的秒利率量级下即使过一整年也远没到 2^256 上限所以不会溢出。存款函数我做了两层一个是存抵押品一个是存借出代币。存抵押品不产生利息只是作为借款保证金function depositCollateral(uint256 amount) external nonReentrant { collateralToken.safeTransferFrom(msg.sender, address(this), amount); users[msg.sender].collateralAmount amount; }存入 USDC 则进入资金池作为借款流动性function deposit(uint256 amount) external nonReentrant { borrowToken.safeTransferFrom(msg.sender, address(this), amount); users[msg.sender].depositAmount amount; totalDeposits amount; emit Deposited(msg.sender, amount); }注意这里我用了nonReentrant修饰器。这是 OpenZeppelin 的重入保护核心作用是把函数内部的状态修改锁住防止外部合约恶意回调同一函数属于借贷合约不可省略的底座。借款逻辑是整个协议最复杂的一段因为要同时校验借款限额和池子流动性function borrow(uint256 amount) external nonReentrant { updateIndex(); UserInfo storage user users[msg.sender]; require(amount 0, amount0); uint256 maxBorrow (_collateralValue(msg.sender) * 10000) / collateralizationRate; require(_currentDebt(msg.sender) amount maxBorrow, exceed borrow limit); require((totalDebtShares * borrowIndex) / RAY amount totalDeposits, lack liquidity); uint256 shares (amount * RAY) / borrowIndex; user.debtShares shares; totalDebtShares shares; borrowToken.safeTransfer(msg.sender, amount); emit Borrowed(msg.sender, amount); }_collateralValue内部计算抵押品价值function _collateralValue(address user) internal view returns (uint256) { return (users[user].collateralAmount * oraclePrice) / 1e18; }_currentDebt读取用户当前债务function _currentDebt(address user) internal view returns (uint256) { return (users[user].debtShares * borrowIndex) / RAY; }借款时先把用户已有的债务加上本次新借款判断是否超过抵押率允许的上限再看整个资金池的可借量是否充足。全部通过后把用户债务份额增加并把借出代币转给用户。这里遵循的是“先校验、再改状态、最后转账”这是最安全的交互顺序。还款函数反过来还的时候先计算用户当前债务和当前指数把还款金额折算成债务份额扣掉function repay(uint256 amount) external nonReentrant { updateIndex(); UserInfo storage user users[msg.sender]; uint256 debt _currentDebt(msg.sender); require(amount 0 amount debt, invalid repay amount); uint256 shares (amount * RAY) / borrowIndex; if (amount debt) { shares user.debtShares; } user.debtShares - shares; totalDebtShares - shares; borrowToken.safeTransferFrom(msg.sender, address(this), amount); emit Repaid(msg.sender, amount); }if (amount debt)这个判断是为了处理全额还款时的精度残留。如果不做这个处理用户还款后可能剩下一两个 wei 的债务份额永远清不掉体验非常差。清算函数是借贷协议的安全阀function isLiquidatable(address user) public view returns (bool) { uint256 debt _currentDebt(user); if (debt 0) return false; return (_collateralValue(user) * 10000) / debt liquidationThreshold; } function liquidate(address borrower, uint256 repayAmount) external nonReentrant { updateIndex(); require(isLiquidatable(borrower), not liquidatable); UserInfo storage b users[borrower]; uint256 debt _currentDebt(borrower); if (repayAmount debt) repayAmount debt; uint256 shares (repayAmount * RAY) / borrowIndex; if (repayAmount debt) shares b.debtShares; uint256 collateralOut (repayAmount * 10000) / 9500; require(collateralOut b.collateralAmount, insufficient collateral); b.debtShares - shares; totalDebtShares - shares; b.collateralAmount - collateralOut; borrowToken.safeTransferFrom(msg.sender, address(this), repayAmount); collateralToken.safeTransfer(msg.sender, collateralOut); }collateralOut repayAmount * 10000 / 9500意味着清算人还 100 USDC 债务可以拿走约 105 USDC 价值的抵押品5% 是给清算人的风险补偿。这里没有考虑价格波动带来的滑点真实环境里清算人可能还要承担短暂的价格冲击成本所以折扣通常可以做成可配置参数。总的来看这套合约的债务记账和清算流程已经有真实协议的雏形。你可以把它当作一个简化版 Compound只是去掉了多资产抵押、存款利息分配、治理投票这些外围功能。3.3 关键安全编码实践写借贷协议时安全不是加分项是生死线。以下几点是我实际编码时反复确认过的第一函数修饰器不能省。我给所有涉及资产转移的公开函数都加了nonReentrant这是最简单的重入防护。同时代码内部坚持“先更新状态再调用外部合约”进一步降低重入风险。第二使用 SafeERC20 处理代币转出。原因不是 OpenZeppelin 流而是某些代币的transfer不返回布尔值比如 USDT 老版本。如果用默认IERC20.transfer去接在 Solidity 0.8 里如果返回值缺失会导致调用 revert但 SafeERC20 内部做了兼容处理。第三注意精度和舍入方向。在份额换算时amount * RAY / borrowIndex会有一个向下取整的问题。对借款人来说向下舍入可能少算一点债务对协议来说累计起来可能就是损失。真实项目里还要考虑 round up 和 round down 的取舍这属于资金安全的细节。第四不要信任外部输入的价格。oraclePrice在教学里写死但生产协议必须接去中心化预言机。如果你直接从某个合约读价格而这个价格可以被操纵攻击者就能反复存取、借还不等直接抽干资金池。这是 DeFi 历史上最惨烈的一类攻击方式。第五权限管理。我的合约没有设计 admin 改参函数只把几个参数写成 public 常量方便演示。真实项目里这些参数应该放在一个可升级的配置中心并通过时间锁和多重签名管理避免单点作恶。4. 测试、部署与常见问题排查4.1 用 Foundry 写核心路径测试合约代码写完下一步是测试。Foundry 的测试文件写起来非常像 Solidity 脚本直接用vm.startPrank切换调用者用vm.warp修改区块时间。下面的测试代码覆盖了存款、借款、还款、清算四条核心路径// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import forge-std/Test.sol; import ../src/SimpleLending.sol; import ../src/MockWETH.sol; import ../src/MockUSDC.sol; contract SimpleLendingTest is Test { MockWETH weth; MockUSDC usdc; SimpleLending lending; address alice address(0xA11CE); address bob address(0xB0B); function setUp() public { weth new MockWETH(); usdc new MockUSDC(); lending new SimpleLending(address(weth), address(usdc)); weth.mint(alice, 10 ether); usdc.mint(alice, 100_000 ether); usdc.mint(bob, 100_000 ether); } function testBorrowAndRepay() public { vm.startPrank(alice); weth.approve(address(lending), 5 ether); lending.depositCollateral(1 ether); usdc.approve(address(lending), 1333 ether); lending.borrow(1000 ether); assertGt(lending.getDebt(alice), 1000 ether); // 模拟10秒后债务增长 vm.warp(block.timestamp 10); uint256 debtAfter lending.getDebt(alice); assertGt(debtAfter, 1000 ether); // 还款 usdc.approve(address(lending), debtAfter); lending.repay(debtAfter); assertEq(lending.getDebt(alice), 0); vm.stopPrank(); } function testLiquidation() public { vm.startPrank(alice); weth.approve(address(lending), 5 ether); lending.depositCollateral(1 ether); usdc.approve(address(lending), 1333 ether); lending.borrow(1000 ether); vm.stopPrank(); // 模拟ETH价格从2000跌到1400抵押率跌到110%以下 lending.setOraclePrice(1400 ether); assertTrue(lending.isLiquidatable(alice)); vm.startPrank(bob); usdc.approve(address(lending), 1000 ether); lending.liquidate(alice, 1000 ether); // 清算人被转走部分抵押品 assertEq(weth.balanceOf(bob), 1000 ether * 10000 / 9500); vm.stopPrank(); } }注意setOraclePrice需要我在合约里留一个测试用的 setter。真实项目建议放到 admin 权限或预言机合约中这里只是为了演示清算逻辑。测试跑完会给你一种踏实感至少业务主链路是通的不会被低级算术错误卡死。4.2 部署到测试网的实操清单测试跑通之后可以部署到测试网。部署前先准备环境变量文件.env把私钥放进.env而不是直接写进脚本PRIVATE_KEY你的私钥 RPC_URLhttps://eth-sepolia.g.alchemy.com/v2/你的KEY然后在foundry.toml中配置默认网络[rpc_endpoints] sepolia ${RPC_URL}部署脚本可以写在script/Deploy.s.sol// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import forge-std/Script.sol; import ../src/SimpleLending.sol; import ../src/MockWETH.sol; import ../src/MockUSDC.sol; contract Deploy is Script { function run() external { uint256 deployer vm.envUint(PRIVATE_KEY); vm.startBroadcast(deployer); MockWETH weth new MockWETH(); MockUSDC usdc new MockUSDC(); SimpleLending lending new SimpleLending(address(weth), address(usdc)); vm.stopBroadcast(); } }然后执行source .env forge script script/Deploy.s.sol --rpc-url $RPC_URL --broadcast部署完成后可以用cast看合约状态。比如查看某个地址的当前债务cast call 合约地址 getDebt(address) 用户地址 --rpc-url $RPC_URL这里强烈建议在测试网阶段把整个流程完整跑一遍包括模拟价格下跌再清算。只有亲手触发过一次清算你才会理解为什么借贷合约对价格灵敏度那么敏感。4.3 常见报错排查速查表我在开发过程中踩过不少坑整理成一张表遇到同样问题可以直接对照。报错信息可能原因解决思路Arithmetic over/underflow0.8 编译器捕获了溢出检查代币 decimals 是否统一检查份额换算时是否先乘后除出现超大中间数execution reverted: amount0传入的借款或还款金额为 0业务函数入口加 amount 0 校验execution reverted: exceed borrow limit抵押品价值不够或用户已有债务含利息后逼近限额查看getDebt是否因为利息增长超过预期也可能是oraclePrice配置错误execution reverted: lack liquidity资金池借出代币余额不足先让测试账户向合约deposit补充流动性execution reverted: invalid repay amount还款金额大于当前债务或金额为 0还款时先调用getDebt获取最新债务金额execution reverted: not liquidatable抵押率还没跌破清算线用setOraclePrice把价格调到清算线以下再触发SafeERC20: low-level call failed代币transfer/transferFrom失败检查是否 approve 足够额度检查代币合约是否有黑名单等特殊逻辑nonce too low使用已打包交易的 nonce 重发检查当前账户 pending 交易或更换测试私钥排除报错时有个习惯很重要先看状态再改参数。每次跑测试或者部署完协议用cast call把关键状态变量打出来比如totalDeposits、borrowIndex、oraclePrice往往能更快定位问题。区块链的状态是透明的调试手段比传统后端少但信息也全所有中间结果都链上可见。最后再分享一个小技巧我在本地开发时经常用 Foundry 自带的主网 fork 功能直接把主网状态拉到本地用真实代币价格跑一轮端到端测试。这样既能避免测试网水龙头等待又能最大程度还原真实环境。你可以在foundry.toml里配一个 fork 地址然后forge test --fork-url $RPC_URL --match-test testLiquidation来跑。这一步能帮你提前发现很多只在真实市场数据下才会暴露的问题。这套借贷协议做到这里已经具备了一个最小 DeFi 产品的全部核心骨架。后续想继续发散可以从三个方向入手把利率模型改成动态利用率模型把oraclePrice替换成真正可靠的预言机再把单抵押品升级成多资产通用池。每当我想再扩展一个功能时都会先把主链路测试跑一遍确认基础账本没有被动过再往上层加东西。DeFi 开发的乐趣也就在这逻辑透明代码即协议每一步都建立在可验证的数学之上。