4、取款逻辑实现:取款函数设计、aToken销毁、流动性检查、取款限额与滑点控制
好,咱们接着聊取款逻辑。存款搞定了,钱怎么取出来?这其实比存款要复杂一些。为什么?因为你要保证协议不会因为用户取款而崩盘。
我个人习惯把取款逻辑拆成四个核心模块:取款函数设计、aToken销毁、流动性检查、取款限额与滑点控制。咱们一个一个来。
4.1 取款函数设计
取款函数,说白了就是用户拿aToken换回底层资产。核心流程很简单:
- 用户调用
redeem()或withdraw() - 协议销毁对应数量的aToken
- 检查池子流动性是否充足
- 把底层资产转给用户
嗯,这里要注意。Aave用的是 withdraw(),Compound用的是 redeem()。两者参数略有不同,但本质一样。
我建议你设计两个入口:
withdraw(asset, amount):用户指定要取多少底层资产redeem(asset, shares):用户指定要销毁多少aToken
为什么搞两个?因为用户习惯不同。有人想「我要取100个USDC」,有人想「我要把我手里的50个aUSDC全换掉」。你都得支持。
核心代码骨架:
function withdraw(address asset, uint256 amount) external returns (uint256) {
// 1. 计算需要销毁的aToken数量
uint256 shares = _convertToShares(asset, amount);
// 2. 销毁用户的aToken
_burn(msg.sender, shares);
// 3. 检查流动性
require(_checkLiquidity(asset, amount), "Insufficient liquidity");
// 4. 转账底层资产
IERC20(asset).safeTransfer(msg.sender, amount);
// 5. 更新状态
_updateState(asset);
return shares;
}
这里有个坑。我在项目中遇到过,有人直接把 amount 当参数传进去,结果用户取款时滑点巨大。你想想看,如果池子流动性突然变差,用户可能取不到预期的数量。所以,滑点控制必须做。
4.2 aToken销毁
aToken销毁,其实就是把用户地址上的aToken余额减掉。但这里有个关键点:销毁前要检查用户余额是否足够。
我曾经踩过一个坑。有个协议没做余额检查,结果用户调用 withdraw() 时,aToken数量不够,但底层资产却转出去了。嗯,那场面,审计报告直接给了个Critical。
正确的做法是:
function _burn(address account, uint256 shares) internal {
require(balanceOf(account) >= shares, "Burn amount exceeds balance");
_totalSupply -= shares;
_balances[account] -= shares;
emit Transfer(account, address(0), shares);
}
另外,销毁时别忘了更新 totalSupply。这个值会影响后面的利率计算,搞错了整个协议就乱套了。
4.3 流动性检查
流动性检查,说白了就是看看池子里还有多少钱。你不能让用户取走超过池子当前余额的钱。
但这里有个微妙的地方:池子里的钱不全是用户的存款。还有一部分是借款人抵押的资产,以及协议积累的利息。
我建议你这样检查:
function _checkLiquidity(address asset, uint256 amount) internal view returns (bool) {
uint256 totalCash = IERC20(asset).balanceOf(address(this));
uint256 totalBorrows = _totalBorrows[asset];
uint256 totalReserves = _totalReserves[asset];
// 可用流动性 = 总现金 - 总借款 - 协议储备金
uint256 availableLiquidity = totalCash - totalBorrows - totalReserves;
return availableLiquidity >= amount;
}
注意: 这里的 totalBorrows 是未偿还的借款总额,不是借款人的抵押品。别搞混了。
你可能会问,为什么还要减去 totalReserves?因为这部分钱是协议赚的利息,属于协议金库,不能随便让用户取走。我见过有些小协议把这部分也算进去了,结果用户取款时把协议利润也取走了,导致协议破产。
4.4 取款限额与滑点控制
取款限额,主要是为了防止大户一次性抽干流动性。你可以设置:
- 单笔限额:每次取款不能超过池子流动性的某个百分比,比如20%
- 时间窗口限额:比如每个地址24小时内最多取100万U
滑点控制,这个更重要。用户取款时,如果池子流动性不足,实际到账的资产可能比预期的少。所以你要让用户设置一个 minAmountOut。
我的经验: 滑点控制参数最好默认设置为0.5%。用户如果觉得不够,可以自己调高。但别设得太低,否则交易容易失败,用户体验很差。
代码实现大概是这样的:
function withdraw(
address asset,
uint256 amount,
uint256 minAmountOut
) external returns (uint256) {
// 检查限额
require(amount <= _withdrawLimit[msg.sender], "Exceeds withdraw limit");
// 计算实际到账
uint256 actualAmount = _calculateWithdrawAmount(asset, amount);
// 滑点检查
require(actualAmount >= minAmountOut, "Slippage too high");
// 更新限额
_withdrawLimit[msg.sender] -= amount;
// ... 后续逻辑
}
嗯,这里还要注意一个细节。取款限额是跟用户地址绑定的,还是跟aToken绑定的?我个人建议跟地址绑定,因为一个用户可能持有多种aToken。如果你按aToken来限制,用户换个aToken就能绕过去,那限制就没意义了。
4.5 总结一下
取款逻辑的核心,其实就是三件事:
- 销毁aToken:确保用户有足够的份额
- 检查流动性:别让池子被抽干
- 控制滑点:保护用户不被割韭菜
我在做第一个DeFi协议时,就是忽略了流动性检查中的储备金部分,结果上线第一天就被套利机器人薅走了几万U。嗯,从那以后,我再也不敢小看取款逻辑了。
下一章,咱们聊聊利率模型。这个更刺激,搞不好整个协议就崩了。