一、先聊聊这个坑是怎么来的
1.1 ERC-4626是干嘛的
咱们先别急着看代码,先打个比方。ERC-4626就像是一个“自动换汇机”,你把一种资产(比如USDC)存进去,它给你一张凭证(份额),这个凭证代表你存了多少。等你不想存了,再把凭证还回去,换回原来的资产。这个过程中,份额和资产之间的换算比例,就是整个协议的核心。
这个标准本来是好事,让不同项目之间的存取逻辑统一了,省得每个项目都自己发明一套。可问题是,计算机在算小数的时候,没法做到绝对精确。尤其区块链上的智能合约,只能用整数运算,一旦涉及到除法,就必然会舍去小数点后面的部分。这个舍入方向如果处理不当,就会被人钻空子。
1.2 舍入问题到底出在哪
咱们平时买菜,四舍五入大家都觉得合理。可在DeFi里,舍入方向直接关系到谁吃亏谁占便宜。比如你存钱的时候,系统给你算份额,如果它向下取整,那你实际拿到的份额就少一点点;反过来,你赎回的时候,如果它向上取整,那你就能多拿到一点点资产。看起来一点点,架不住有人反复操作。
更有意思的是,如果合约里同时存在“向下取整”和“向上取整”,而且没有做补偿,那么每一笔交易都可能给攻击者留下“刮羊毛”的空间。时间一长,池子里的资产和份额就对不上了,也就是所谓的“失衡”。咱们下面用一个具体的例子来感受一下。
二、一个让人头大的实际例子
2.1 用代码演示漏洞
先说明一下技术栈,咱们下面所有代码都是Solidity,用来写以太坊智能合约的。我写一个简化版的ERC-4626,故意留一个舍入漏洞。
// 技术栈:Solidity(以太坊智能合约)
pragma solidity ^0.8.0;
// 一个简化版ERC-4626,只保留存取和换算逻辑
contract Simple4626 {
// 用户存入的底层资产(比如USDC)
uint256 public totalAssets;
// 用户持有的份额总数
uint256 public totalShares;
// 存入资产:给你多少份额?
// 这里直接用向下取整,会少给份额
function deposit(uint256 assets) public returns (uint256 shares) {
// 如果池子里还没有份额,就1:1给
if (totalShares == 0) {
shares = assets;
} else {
// 按比例算:份额 = 资产 * 总份额 / 总资产
// 这里用除法,solidity默认向下取整
shares = assets * totalShares / totalAssets;
}
// 更新账本(真实转账逻辑省略)
totalShares += shares;
totalAssets += assets;
}
// 赎回份额:给你多少资产?
// 这里也是向下取整,会少给资产
function redeem(uint256 shares) public returns (uint256 assets) {
// 资产 = 份额 * 总资产 / 总份额
// 同样向下取整
assets = shares * totalAssets / totalShares;
totalShares -= shares;
totalAssets -= assets;
}
// 算一下现在的汇率,比如1份额值多少钱
function convertToAssets(uint256 shares) public view returns (uint256) {
return shares * totalAssets / totalShares;
}
}
你看到问题了吗?deposit时向下取整,redeem时也向下取整。表面看,用户总是少拿一点,合约好像“占便宜”。但有人会利用这个特性做“存款-赎回”的循环操作,把那一丁点差额累积起来。
2.2 攻击者怎么薅羊毛
我们设想一个场景:池子里刚有人存了1000个代币,对应1000份额,汇率1:1。攻击者先存入1个代币,按比例算份额:11000/1000=1,没问题。然后他立刻赎回1份额,按比例算资产:11001/1001=1,也没问题。这看起来没毛病。
但关键在于,当池子里有零头的时候。比如有人存了3个,然后存了2个,再取走一些,池子里的总资产和总份额就会产生无法整除的余数。攻击者只要反复进行极小额的存取,就能把余数“抖”出来。每一笔操作只能赚几个wei(以太坊最小单位),但架不住量大,一年下来,池子里的储备就被掏空了一小块。
更极端的攻击是“通胀攻击”:攻击者先往池子里转到期的资产,但不铸造份额,把汇率抬高。然后别人来存钱时,由于向下取整,可能得到0份额,但资产却被扣走了。这就不是小额薅羊毛了,而是直接抢钱。
三、WadRay数学库是什么来头
3.1 原理很简单
WadRay数学库其实不是什么高深的东西,它就是一套用整数表示小数的规则。Wad是用10的18次方来表示1,Ray是用10的27次方来表示1。为啥用这两个数?因为以太坊上最常见的代币是18位小数,Wad正好对应;Ray精度更高,适合做利率计算。
平时我们写合约,如果直接做除法,小数部分就被砍掉了。用WadRay库,可以先放大再运算,最后再缩回,这样能最大程度保留精度。最关键的是,库里面提供两种除法:一种是“向下取整”(wdivDown),一种是“向上取整”(wdivUp)。有了这两个工具,我们就可以在不同的场景里选择正确的舍入方向。
3.2 怎么用它来防御
防御的思路其实一句话:让合约自己承担舍入误差,而不是让用户承担。具体说,就是存款的时候,给用户的份额要“向下取整”,宁可少给用户一点点,也不能多给;赎回的时候,给用户的资产要“向下取整”,宁可少给用户一点点。这样看起来合约总是“占便宜”,但池子里的资产量会慢慢积累一点余数,不会亏空。
反过来,如果想让用户体验更好,也可以采用“存款向上取整,赎回向下取整”,但这需要一个缓冲机制来垫付差额。WadRay库让这两个方向都能精确控制,不会出现“两边都向下”还导致汇率漂移的情况。
四、防御策略实战
4.1 关键点一:向上取整和向下取整
我们重新写一个安全的合约,用WadRay库的取整函数。注意看注释。
// 技术栈:Solidity(以太坊智能合约)+ WadRay数学库
pragma solidity ^0.8.0;
// 引入WadRay库(这里只展示核心函数)
library WadRay {
uint256 internal constant WAD = 1e18;
// 向下取整除法:结果永远不大于真实值
function wdivDown(uint256 a, uint256 b) internal pure returns (uint256) {
// 先乘后除,减少精度损失
return a * WAD / b;
}
// 向上取整除法:结果永远不小于真实值
function wdivUp(uint256 a, uint256 b) internal pure returns (uint256) {
// 分子加分母-1,实现向上取整
return (a * WAD + b - 1) / b;
}
// 乘法也提供两种方向,这里用普通乘法
function wmul(uint256 a, uint256 b) internal pure returns (uint256) {
return a * b / WAD;
}
}
// 安全版4626
contract Safe4626 {
using WadRay for uint256; // 让uint256拥有库里的方法
uint256 public totalAssets;
uint256 public totalShares;
// 存款:给用户的份额向下取整,合约留余数
function deposit(uint256 assets) public returns (uint256 shares) {
if (totalShares == 0) {
shares = assets;
} else {
// 注意这里用wdivDown
shares = assets.wdivDown(totalAssets) * totalShares / 1e18;
}
require(shares > 0, "shares must be positive"); // 防止0份额攻击
totalShares += shares;
totalAssets += assets;
}
// 赎回:给用户的资产向下取整,合约留余数
function redeem(uint256 shares) public returns (uint256 assets) {
// 用wdivDown确保不会多给
assets = shares.wdivDown(totalShares) * totalAssets / 1e18;
totalShares -= shares;
totalAssets -= assets;
}
}
注意,这里wdivDown的用法是先算assets * 1e18 / totalAssets,得到一个wad精度的汇率,再乘以总份额。真实项目里会直接用库函数,我只是演示思路。另外,deposit里加了require(shares > 0),防止攻击者用极小金额换0份额,然后“免费”刷汇率。
4.2 关键点二:缓冲机制
除了取整方向,还有一个常见防御手段叫“虚拟份额”或“缓冲垫”。简单说,合约在初始化的时候,自己先偷偷铸造1000份额存着,这部分份额不归任何人。这样即使攻击者把其他人的份额全赎回,池子里还有一层“垫子”吸收误差,不会直接归零。来看代码。
// 技术栈:Solidity(以太坊智能合约)
contract Buffer4626 {
uint256 public totalAssets;
uint256 public totalShares;
uint256 private constant BUFFER_SHARES = 1000; // 缓冲份额
// 构造函数,初始化时给合约自己留一点份额
constructor() {
totalShares = BUFFER_SHARES; // 这1000份没有对应资产,是虚拟的
}
// 存款
function deposit(uint256 assets) public returns (uint256 shares) {
if (totalShares == 0) {
shares = assets;
} else {
// 这里需要把虚拟份额也算进去,否则汇率会失真
shares = assets * totalShares / (totalAssets + BUFFER_SHARES);
}
totalShares += shares;
totalAssets += assets;
}
// 赎回
function redeem(uint256 shares) public returns (uint256 assets) {
assets = shares * totalAssets / (totalShares - BUFFER_SHARES);
totalShares -= shares;
totalAssets -= assets;
}
}
这个缓冲机制的原理是:虚拟份额让总份额永远大于实际资产对应的份额,这样即使出现极端的“撇脂”操作,也会先亏缓冲份额,不会动到真实用户的钱。不过要注意,虚拟份额的数值得仔细设计,太小没用,太大又会稀释早期用户的收益。
五、应用场景
这种精度防御策略,在哪些地方最需要?
第一,借贷协议。用户存进去的是抵押品,借出来的是稳定币。如果汇率算错一点,清算的时候就会少给抵押品,引发连环爆仓。WadRay库的向上/向下取整能保证清算方和借款方都得到公平的数值。
第二,收益聚合器。这类协议把钱放到各种策略里赚利息,用户按份额分红。如果分红的计算有误差,哪怕只有几个wei,聚合器积攒百万用户后,就会积累一笔可观的“误差资金”,很可能被恶意用户提取。
第三,指数基金或合成资产。这些协议需要精确计算一篮子代币的比例,舍入误差会导致某个成分被多买或卖,引发价格偏离。通过强制取整方向,可以保证每次调仓都在可控范围内。
其实,只要涉及“份额”和“资产”互相转换的合约,都应该思考这个问题。ERC-4626作为标准,很多衍生协议都直接复用它的换算逻辑,所以这个坑的影响面比想象中要宽得多。
六、技术优缺点
6.1 优点
第一,安全性高。用WadRay库的显式取整,能保证合约在任何情况下都不会“亏空”,至少不会因为舍入问题被薅秃。第二,代码可读性好。库的命名非常直白,wdivDown和wdivUp一眼就能看出是向上还是向下,不容易出错。第三,生态成熟。WadRay已经被Aave等大项目使用过,经过了实战考验,比你自己造轮子要稳妥得多。
6.2 缺点
第一,精度有上限。Wad是18位,Ray是27位,虽然很高,但某些极端场景(比如极其微小的利率)还是可能不够。第二,Gas费用略高。因为计算更复杂,且需要调用库函数,比直接做除法多花一点Gas。不过这点费用在以太坊主网上几乎可以忽略不计。第三,容易误用。如果开发者分不清什么时候该向上、什么时候该向下,反而会引入新的bug。比如在赎回时错误地使用了向上取整,就可能导致合约被抽干。所以学习成本是存在的。
七、注意事项
自己动手实现时,有几个坑一定要避开。
第一,不要混用取整方向。比如存款用向下取整,赎回却用向上取整,那合约就亏定了。要统一规则:要么都给用户占便宜,要么都让合约占便宜。通常推荐“向下取整+缓冲份额”的组合。
第二,小心0份额攻击。就像前面代码里写的,如果存款时算出份额为0,你直接返回0,用户会白白损失资产。正确的做法是require(shares > 0),或者规定最小存款金额。
第三,对账一定要做。上线前用脚本模拟大量随机存取,看最后的总资产和总份额是否对得上。我见过很多项目,测试时只跑了一两个用例,根本没暴露舍入问题,上线才两天就被薅了。
第四,注意虚拟份额的会计处理。刚才的缓冲例子中,虚拟份额会影响汇率计算。如果你在多个函数里都直接用totalShares来算,就会出现前后不一致。最好把“有效的用户份额”和“虚拟份额”分开存储,避免混乱。
第五,尽量引用经过审计的库。OpenZeppelin有现成的Math库,Aave的WadRayMath也可以直接用。自己写虽然爽,但审计成本会翻倍,没必要。
八、总结
精度舍入看起来是个小问题,实际却是DeFi里最常见的“蚂蚁搬家”式漏洞。ERC-4626标准把存取逻辑统一了,但并没有规定舍入方向,所以每个项目都得自己做决定。WadRay数学库提供了明确的向上/向下取整函数,配合缓冲份额,能有效防止池子失衡。
最后给你一个实用的建议:写合约的时候,先问自己一句——“如果这笔交易被无限次重复,合约里的钱会不会越来越少?”如果会,那就赶紧把取整方向调对,加一层缓冲。别嫌麻烦,安全永远比省事重要。
Comments