本文详细介绍了rebase 代币的概念及其实现,通过设计一个基于ERC-20标准的重设代币合约,讨论了其逻辑、可能的安全问题及相应的代码实现。文章对交易、铸造、燃烧等过程进行了深入的分析,并提出了防止攻击的措施,适合对DeFi和智能合约开发有一定了解的读者。
一个“Rebase Token”(有时称为“ rebasing 代币”)是一个 ERC-20 Token,其总供应量和Token持有者的余额可以在没有转移、铸造或销毁的情况下进行更改。
DeFi 协议通常使用Rebase Token来追踪其对存款者应付的资产金额——包括协议获得的利润。例如,如果协议欠存款者 10 ETH(包括利润),那么存款者的Rebase Token ERC-20 余额将为 10e18。如果他们的存款增值到 11 ETH,他们的余额将“Rebase ”到 11e18。
本文解释了如何编写Rebase Token的代码,以及代码背后的逻辑。
我们还涵盖了创建Rebase Token时可能出现的潜在安全问题。
考虑以下说明Rebase Token的示例:
rbLP.balanceOf(alice)
,它将返回 100(小数点后有 18 位)。rbLP.balanceOf(alice)
将返回 110。Rebase Token试图保持Rebase Token的总供应量等于池中持有的 ETH 总量。实际上,由于四舍五入误差,池中的 ETH 可能略多于Rebase Token的总供应数量——我们将稍后讨论这一点。
用户拥有的 rbLP 代币余额是他们可以从池中赎回的 ETH 数量。因此,用户的余额可以解释为他们在Rebase Token总供应中的“份额”(或等价于池中持有的 ETH 数量)。
在本文的其余部分,我们将把存款资产称为 ETH,但当然它也可以是其他 ERC-20 Token。
我们将创建一个Rebase ERC-20 Token。ERC-20 Token合约的总供应量是Token持有的以太数量(这意味着我们的Rebase Token有 18 位小数)。我们有时将“Token”与“池”互换使用。可以将其视为一个实施 ERC-20 标准的池(但带有Rebase )来追踪谁所欠如何的 ETH。
该设计受到了 Lido stETH Token 的深刻启发。
在传统的 ERC-20 中,用户的余额只是与地址相关联的数字,存在于 mapping(address => uint256)
中。
在Rebase ERC-20 代币中,映射中持有的值代表用户对池的部分所有权。最好将映射视为持有“份额(share)”。
mapping(address => uint256) internal _shareBalance;
总供应量的分数可以计算为 _shareBalance[user] / _totalShares
,其中 _totalShares
是所有用户的份额总和。
假设Alice拥有池中 70% 的 ETH,Bob拥有 30% 的 ETH。一个有效的份额(share)分配可以是:
但我们只关心份额作为所有权的比例。以下份额分配在相同情况下也是有效的:
Rebase Token的 balanceOf 表示用户可以赎回的 ETH 数量。某些数量的份额所能赎回的 ETH 数量为:
$$ \frac{\texttt{num_share}}{\texttt{total_shares}}\times\texttt{ETH_balance} $$
使用第二个示例,其中Alice有 35 份,Bob有 15 份,我们可以看到Alice可以赎回池中 70% 的 ETH:
$$ \frac{35}{35+15}=0.7 $$
将公式翻译为 Solidity,我们得到:
function balanceOf(address account) public view returns (uint256) {
if (_totalShares == 0) {
return 0;
}
return _shareBalance[account] * address(this).balance / _totalShares;
}
变量 _totalShares
在 mint()
和 burn()
的过程中更新,当 ETH 被添加到池中或从池中移除时。
在 mint()
过程中,存款者将以太添加到池中并铸造相应数量的份额,代表其所有权比例。
如果他们是第一个铸造者,则铸造的份额数量仅为 msg.value
。否则,他们必须保持比例:
$$ \frac{\text{totalSharesPrevious}}{\text{totalBalancePrevious}}=\frac{\text{totalSharesPrevious}+\text{sharesToCreate}}{\text{totalBalancePrevious}+\texttt{msg.value}}
$$
这可以理解为“份额所能赎回的余额不会因铸造而改变。”
为了解出 sharesToCreate
,我们把变量简化为:
$$ \frac{s}{b}=\frac{s+c}{b+v} $$
其中:
msg.value
。我们可以通过以下代数提取 c:
$$ \begin{align} \frac{s}{b}=\frac{s+c}{b+v}\ \frac{s\cdot(b+v)}{b}=s+c\ \frac{sb+sv}{b}=s+c\ s+\frac{sv}{b}=s+c\ \frac{sv}{b}=c\ \end{align} $$
因此,sharesToCreate = sharesPrevious * msg.value / balancePrevious
。然而,balancePrevious
不是我们存储的东西,但可以计算为 address(this).balance - msg.value
。因此,mint()
的代码如下(以下代码尚未完全安全!):
function mint(address to) external payable {
require(to != address(0), ERC20InvalidReceiver(to));
uint256 sharesToCreate;
if (_totalShares == 0) {
sharesToCreate = msg.value;
} else {
sharesToCreate = msg.value * _totalShares / (address(this).balance - msg.value);
}
_totalShares += sharesToCreate;
_shareBalance[to] += sharesToCreate;
uint256 balance = sharesToCreate * address(this).balance / _totalShares;
emit Transfer(address(0), to, balance);
}
请注意,address(this).balance
和 totalShares
以相同的百分比增加。因此,比例
$$ \texttt{balanceOf_user}=\frac{\texttt{address(this).balance}\times\texttt{shares[user]}}{\texttt{totalShares}} $$
在铸造过程中保持几乎不变。这是因为计算 sharesToCreate
涉及除法,对于铸造者而言,创建的份额数量可能略少于它应有的数量,意味着他们的百分比所有权被稍微低估。这意味着其他用户的百分比所有权可能会有轻微增加。
然而,如果有人将 ETH 直接转移到合约,或合约在 ETH 中获利(即,不是通过铸造),那么余额将增加,但 _totalShares
不会。这将导致公式中上述比例的值增加,从而导致余额向上Rebase 。
请注意,攻击者可以通过利用闪电贷临时增加其余额,从而铸造Rebase Token。因此,任何业务关键逻辑都不应盲目依赖 balanceOf()
或 totalSupply()
。
还值得注意的是,在公式中:
sharesToCreate = msg.value * _totalShares / (address(this).balance - msg.value);
如果:
_totalShares
相对较低(即协议已获得大量利润)msg.value
较小则 sharesToCreate
可能会四舍五入到零。
由于可能会导致份额向下四舍五入为零,当前实现容易受到小额存款攻击。
具体来说:
在步骤 2 中,sharesToCreate
将计算为:
$$ \underbrace{10^{18}}\text{msg.value}\cdot\underbrace{1}\text{total shares}/(\underbrace{(101\cdot10^{18}+1)}\text{address(this).balance}-\underbrace{10^{18}}\text{msg.value}) $$
这将四舍五入到零,因为分母大于分子。现在,受害者已经存入 1 ether,结果没有铸造到任何份额。攻击者拥有了所有份额,因此掌控了受害者的存款。
因此,我们的Rebase Token必须实施某种滑点保护。
我们可以创建一个“minimumShares”参数,但这会将份额的抽象泄漏给用户。换句话说,集成商现在必须将“份额”视为与“余额”分开的一个值。
一种不需要了解份额的替代安全措施是检查 sharesToCreate / _totalShares
的比例是否接近 msg.value / address(this).balance
。如果 sharesToCreate
四舍五入得太多,则比例 sharesToCreate / _totalShares
将远小于相对于总余额存入的以太。
由于 sharesToCreate
略微向下四舍五入,我们检查:
sharesToCreate / _totalShares >= slippage * msg.value / address(this).balance
其中 slippage 是一个像 0.999 的值,如果滑点期望为 0.1%。当然,我们无法在 Solidity 中表达 0.999,因此我们可以使用基点(1 基点是 0.01%,10,000 基点是 100%)。这导致了以下公式:
// slippageBp 是基点,因此 9900 意味着我们容忍 1% 的滑点
sharesToCreate / totalShares >= slippageBp / 10_000 * msg.value / address(this).balance
为了消除将四舍五入到零的分数,我们将不等式的两边都乘以 _totalShares * 10_000 * address(this).balance
。这给我们带来了
sharesToCreate * 10_000 * address(this).balance >= slippageBp * msg.value * _totalShares
因此,我们的铸造函数可以更新如下:
function mint(address to, uint256 slippageBp) external payable {
require(to != address(0), ERC20InvalidReceiver(to));
uint256 sharesToCreate;
if (_totalShares == 0) {
sharesToCreate = msg.value;
} else {
sharesToCreate = msg.value * _totalShares / (address(this).balance - msg.value);
}
_totalShares += sharesToCreate;
_shareBalance[to] += sharesToCreate;
require(sharesToCreate * 10_000 * address(this).balance >= slippageBp * msg.value * _totalShares, "slippage");
emit Transfer(address(0), to, msg.value);
}
通过不在写入之后立即从存储中读取 _totalShares
和 _shareBalance[to]
,还有优化Gas的空间,但为简单起见我们没有展示这种优化。
如前所述,Rebase Token的总供应量是池中持有的 ETH:
function totalSupply() public view returns (uint256) {
return address(this).balance;
}
在这个阶段,介绍一个辅助功能 amountToShares
是有用的。它是 balanceOf()
使用的公式的反函数。假设用户想要销毁(或转移)他们的全部余额。这相当于多少份额?
为了计算这一点,我们求解 balanceOf
方程以获得 _shareBalance[user]
:
balance=address(this).balance×sharestotalShares
在两边都乘以 totalShares 并除以 address(this).balance 后,我们得到:
shares=balance×totalSharesaddress(this).balance
因此,要将余额转换为份额,我们使用以下函数:
function _amountToShares(uint256 amount) internal view returns (uint256) {
if (address(this).balance == 0) {
return 0;
}
return amount * _totalShares / address(this).balance;
}
现在,当用户指定他们希望销毁的基础代币(在我们的案例中是 ETH)的“金额”时,我们可以直接将其转换为份额。
销毁的参数是他们希望销毁的余额,而不是份额。在销毁过程中,我们将销毁的数量转换为份额数量,然后从用户的份额和总份额中扣除这些份额。
function burn(address from, uint256 amount) external {
_spendAllowanceOrBlock(from, msg.sender, amount);
uint256 shares = _amountToShares(amount);
require(shares > 0, "zero shares");
_shareBalance[from] -= shares;
_totalShares -= shares;
(bool ok,) = from.call{value: amount}("");
require(ok, ERC20InvalidReceiver(from));
emit Transfer(from, address(0), amount);
}
Rebase Token的余额是他们可以提取的 ETH 数量。因此,amount
参数正是他们在 burn()
结束时转移给他们的 ETH 数量。
在扣除之前不必检查用户的余额,因为 Solidity(版本 0.8.0 或更高版本)在下溢时会还原。
函数 _spendAllowanceOrBlock()
我们将在后面的部分重新访问。
require(shares > 0, "zero shares");
是为了防止如果 shares
四舍五入到 0 而从合约中转移以太。请回想一下,_amountToShares
的计算为 amount * _totalShares / address(this).balance;
。如果 amount * _totalShares
小于 address(this).balance
,那么 shares
将四舍五入为 0。因此,调用者可能会在没有这一要求的情况下从合约中提取 amount
。
transfer
和 transferFrom
的功能与 burn
类似,不同之处在于没有销毁份额和发送 ETH,而是将份额记入另一帐户,并且没有 ETH 转移:
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(to != address(0), ERC20InvalidReceiver(to));
_spendAllowanceOrBlock(from, msg.sender, amount);
uint256 shareTransfer = _amountToShares(amount);
_shareBalance[from] -= shareTransfer;
_shareBalance[to] += shareTransfer;
emit Transfer(from, to, amount);
return true;
}
由于 amountToShares
执行了计算 amount * _totalShares / address(this).balance
,因此转移的份额数量可能会向下四舍五入。
由于除法向下四舍五入,这意味着 to
的余额可能比 amount
小一些。
这是Rebase Token的一般性问题——请参见 Lido 对此问题的文档,了解 stETH 如何处理。
因此,用户有可能销毁他们的全部余额,但仍然剩下小额余额,因为计算要销毁的份额数量向下四舍五入超出了他们实际拥有的份额数量。因此,我们不应假设当销毁全部余额时,份额余额就会归零。
没有“正确”的方法来实现Rebase Token的授权和批准,因为Rebase ERC-20 Token没有规定其行为的标准。
然而,大多数Rebase Token使用的允许机制类似于常规 ERC-20 Token——但允许额度不和重新调整。
这种机制的缺点在于,如果Alice为Bob批准了她的全部余额,但在Bob从Alice转账之前发生了一次Rebase ,那么Bob将无法提取她的全部余额。
Rebase Token Compound Finance 使用的机制将此问题解决,仅允许“全部或无”批准。调用 approve
时,可批准的数量只能为 0 或 type(uint256).max
——但这可能会破坏与规定转移金额的协议的集成。另一方面,AAVE 和 stETH(Lido)的Rebase Token的授权和批准行为则像常规 ERC-20,不会因Rebase 而调整。
因此,我们的Token的批准逻辑与 OpenZeppelin 非常相似。我们实现了函数 _spendAllowanceOrBlock()
,如其名称所示,这个函数消费支出者的允许额度,如果允许额度不足则还原。在我们的实现中,如果 msg.sender == spender
,我们将不消费允许额度,当允许额度为 type(uint256).max
时我们也不扣减允许额度。
我们将在下一节中展示完整的实现。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
contract RebasingERC20 is IERC20Errors, IERC20 {
uint256 internal _totalShares;
mapping(address => uint256) public _shareBalance;
mapping(address owner => mapping(address spender => uint256 allowance)) public allowance;
receive() external payable {}
function mint(address to, uint256 slippageBp) external payable {
require(to != address(0), ERC20InvalidReceiver(to));
require(msg.value > 0);
uint256 sharesToCreate;
if (_totalShares == 0) {
sharesToCreate = msg.value;
} else {
uint256 prevBalance = address(this).balance - msg.value;
sharesToCreate = msg.value * _totalShares / prevBalance;
require(sharesToCreate > 0);
}
_totalShares += sharesToCreate;
_shareBalance[to] += sharesToCreate;
require(sharesToCreate * 10_000 * address(this).balance >= slippageBp * msg.value * _totalShares, "slippage");
uint256 balance = sharesToCreate * address(this).balance / _totalShares;
emit Transfer(address(0), to, balance);
}
function burn(address from, uint256 amount) external {
_spendAllowanceOrBlock(from, msg.sender, amount);
uint256 shares = _amountToShares(amount);
require(shares > 0);
_shareBalance[from] -= shares;
_totalShares -= shares;
(bool ok,) = from.call{value: amount}("");
require(ok, ERC20InvalidReceiver(from));
emit Transfer(from, address(0), amount);
}
function _amountToShares(uint256 amount) public view returns (uint256) {
if (address(this).balance == 0) {
return 0;
}
return amount * _totalShares / address(this).balance;
}
function totalSupply() public view returns (uint256) {
return address(this).balance;
}
function balanceOf(address account) public view returns (uint256) {
if (_totalShares == 0) {
return 0;
}
return _shareBalance[account] * address(this).balance / _totalShares;
}
function transfer(address to, uint256 amount) external returns (bool) {
transferFrom(msg.sender, to, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(to != address(0), ERC20InvalidReceiver(to));
_spendAllowanceOrBlock(from, msg.sender, amount);
uint256 shareTransfer = _amountToShares(amount);
_shareBalance[from] -= shareTransfer;
_shareBalance[to] += shareTransfer;
emit Transfer(from, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function _spendAllowanceOrBlock(address owner, address spender, uint256 amount) internal {
if (owner != msg.sender && allowance[owner][spender] != type(uint256).max) {
uint256 currentAllowance = allowance[owner][spender];
require(currentAllowance >= amount, ERC20InsufficientAllowance(spender, currentAllowance, amount));
allowance[owner][spender] = currentAllowance - amount;
}
}
}
请注意,上述代码中未实现 name()
、symbol()
和 decimals()
。
如果有人在一个人已经铸造Rebase Token后向合约转移 ETH,则铸造者的余额将向上重新基。
但是,如果有人在任何人铸造之前向合约转移 ETH,则第一个铸造者将控制该 ETH,他们的余额将等于池中的所有 ETH,因为他们拥有所有未发生的份额。
一些协议为节省Gas效率而缓存 ERC-20 Token的余额——但如果代币Rebase ,这可能会破坏其逻辑。
许多协议不会像我们上面的示例那样自动Rebase 。相反,他们每天天Rebase 或在其他某个周期内Rebase 。这可能是必要的,如果资产未在合约中持有(例如,它在以太坊验证者中被质押),或者如果资产的价值依赖于预言机。
在与Rebase Token互动时,转移中指定的 amount
可能与余额变化不相等,因为份额分配的四舍五入。因此,最好使用以下逻辑来确定实际存入的金额:
uint256 balanceBefore = token.balanceOf(address(this));
token.transferFrom(sender, address(this), amount);
uint256 trueTransferAmount = token.balanceOf(address(this)) - balanceBefore;
Ampleforth 和 OlympusDao 的 sOHM Token 是另外两个值得注意的Rebase Token。Ampleforth 使用Rebase Token动态地将Rebase Token的价值与其他资产Hook。为了增加代币的价值,它向下Rebase (导致其更稀缺);当它需要降低代币的价值时,它则向上Rebase 以造成通货膨胀。
我们要感谢来自 Pashov Audit Group 的 MerlinBoii 和 deadrosesxyz 对早期版本此文章的建议修订。我们还要感谢 ChainLight 对本文章最后的参考合约进行审核,并在早期实施中识别出一个严重漏洞(审核报告)。
- 原文链接: rareskills.io/post/rebas...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!