“rebase token”(有时称为“rebasing token”)是一种 ERC-20 代币,其总供应量和代币持有者的余额可以在不发生转账、铸造或销毁的情况下发生改变。
DeFi 协议通常使用 rebasing tokens 来跟踪其欠存款人的资产数量——包括协议产生的利润。例如,如果协议欠存款人 10 ETH(含利润),则该存款人用于该 rebasing token 的 ERC-20 余额将是 10e18。如果他们的存款价值增加到 11 ETH,他们的余额将“rebase”至 11e18。
本文将解释如何编写 rebasing token 的代码,以及代码背后的逻辑。
我们还将探讨在创建 rebasing token 时可能出现的潜在安全问题。
Rebase 代币交易示例
考虑以下说明 rebasing tokens 的示例:
- Alice 向池子存入 100 ETH。池子为她铸造了 100 rbLP(rebasing LP 代币)。
- Alice 可以销毁这 100 rbLP 并拿回她的 100 ETH。她的 rbLP 余额即为她可以从池子中赎回的 ETH 数量。
- 但假设池子获得了 10% 的利润,例如通过借贷手续费。她的 100 rbLP 余额将自动“rebase”至 110 rbLP。
- 也就是说,如果我们在她刚存款时调用
rbLP.balanceOf(alice),它将返回 100(带有 18 位小数)。 - 产生利润后,
rbLP.balanceOf(alice)将返回 110。
- 也就是说,如果我们在她刚存款时调用
- 现在,在池子产生利润后,Bob 向池子存入 100 ETH。池子将为他铸造 100 rbLP。然而,Alice 拥有 110 rbLP,因为她在池子产生 10% 利润之前就已经在提供流动性了。
Rebase 代币试图保持 rebase 代币的总供应量等于池子中持有的 ETH 总量。在现实中,由于舍入误差,ETH 的数量可能会略微多于 rebase 代币的总供应量——我们将在稍后讨论这一点。
用户所拥有的 rbLP 代币余额就是他们可以从池子中赎回的 ETH 数量。因此,用户的余额可以被理解为他们在 rebasing token 总供应量中所占的“份额”(或者等同于池子所持有的 ETH 数量)。
在本文的剩余部分中,我们将把存入的资产统称为 ETH,当然,它也可以是其他的 ERC-20 代币。
设计一个 Rebasing 代币
我们将创建一个 rebasing ERC-20 代币。该 ERC-20 代币合约的总供应量是该代币所持有的 Ether 数量(这意味着我们的 rebasing token 具有 18 位小数)。我们有时会将“代币”(token)与“池子”(pool)交替使用。你可以将其视为一个实现了 ERC-20 标准(但带有 rebasing 机制)的池子,用于追踪欠谁多少 ETH。
这种设计在很大程度上受到了 Lido stETH token 的启发。
balanceOf()
在传统的 ERC-20 中,用户的余额仅仅是在 mapping(address => uint256) 中与地址关联的一个数字。
在一个 rebasing ERC-20 代币中,映射(mapping)中保存的值代表了用户在池子中所占的部分所有权。最好将该映射视为持有的“份额”(shares)。
mapping(address => uint256) internal _shareBalance;
在总供应量中的占比可以计算为 _shareBalance[user] / _totalShares,其中 _totalShares 是所有用户份额的总和。
假设 Alice 拥有池子中 70% 的 ETH,Bob 拥有池子中 30% 的 ETH。一个有效的份额分配方式可能是:
- Alice: 70 shares
- Bob: 30 shares
但我们只关心作为所有权比例的份额。在同样的情况下,以下份额分配也是有效的:
- Alice: 35 shares
- Bob: 15 shares
rebase 代币的 balanceOf 代表了用户可以赎回的 ETH 数量。一定数量的份额能够赎回的 ETH 数量为:
使用第二个示例,即 Alice 拥有 35 份额,Bob 拥有 15 份额,我们可以看到 Alice 可以赎回池子中 70% 的 ETH:
将该公式转换为 Solidity,我们得到:
function balanceOf(address account) public view returns (uint256) {
if (_totalShares == 0) {
return 0;
}
return _shareBalance[account] * address(this).balance / _totalShares;
}
当 ETH 从池子中添加或移除时,变量 _totalShares 会在 mint() 和 burn() 期间进行更新。
mint()
在 mint() 期间,存款人将 Ether 添加到池子中,并铸造一定数量的份额,该份额代表了他们在未偿还份额总比例中的所有权百分比(未偿还份额即存在的全部份额总和,或者所有用户的 _shareBalance[user] 之和)。
如果他们是第一个铸造者,那么铸造的份额数量就是 msg.value。否则,他们必须维持以下比例:
这种说法可以理解为“每一份份额能够赎回的余额数量不会因铸造操作而改变”。
为了求解 sharesToCreate,让我们将变量简写如下:
其中:
- 是之前的份额
- 是之前的余额
- 是要创建的份额,并且
- 是
msg.value。
我们通过以下代数运算可以提取出 :
因此,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 会增加相同的百分比数额。因此,比率:
在铸造期间基本上保持不变。这是因为在计算 sharesToCreate 时涉及到了除法,为铸造者创建的份额数量可能会比理论值略少一点,这意味着他们所有权百分比的表现稍有不足。这也意味着其他用户的所有权百分比可能会经历极其轻微的上升。
但是,如果有人将 ETH 直接转入合约,或者合约以 ETH 的形式获取了利润(即不是通过他人的铸造操作),那么余额将会增加,而 _totalShares 不会增加。这将使上述公式中比率的值增加,导致余额向上 rebase。
需要注意的是,攻击者可以通过使用闪电贷(flashloan)铸造 rebasing token 来暂时增加他们的余额。因此,任何核心业务逻辑都不应盲目依赖 balanceOf() 或 totalSupply()。
同样值得注意的是,在这个公式中:
sharesToCreate = msg.value * _totalShares / (address(this).balance - msg.value);
sharesToCreate 有可能会向下取整变为 0,如果:
- 池子拥有巨大的 ETH 余额
_totalShares相对较低(即协议已经赚取了大量利润)msg.value极小
因为份额有可能向下取整到 0,所以当前的实现容易受到小额存款攻击(small deposit attack)的影响。
具体而言:
- 攻击者铸造 1 wei
- 攻击者向池子捐赠 100 ether,借此在受害者铸造 1 ether 的交易前进行抢跑(frontruns)。
在第 2 步中,sharesToCreate 的计算方式将为:
由于分母大于分子,结果将向下取整为 0。现在受害者存入了 1 ether,但未被铸造出任何代币。攻击者拥有全部的份额,从而控制了受害者的存款。
因此,我们的 rebase 代币必须实现某种形式的滑点保护(slippage protection)。
我们可以创建一个 “minimumShares” 参数,但这会向用户泄漏关于份额的抽象概念。换句话说,集成者现在不得不将“份额”(shares)视为一个有别于“余额”(balances)的独立值来进行考虑。
一种无需了解份额概念的替代安全措施是,检查 sharesToCreate / _totalShares 的比率是否接近 msg.value / address(this).balance。如果 sharesToCreate 向下取整太多,那么 sharesToCreate / _totalShares 的比率将远远小于所存 ether 相对于总余额的比率。
由于 sharesToCreate 会产生轻微的向下取整,我们通过以下条件进行检查:
sharesToCreate / _totalShares >= slippage * msg.value / address(this).balance
如果期望的滑点为 0.1%,那么这里的 slippage 应该是一个类似于 0.999 的值。当然,我们无法在 Solidity 中表示 0.999,因此我们可以使用基点(basis points)来代替(1 个基点是 0.01%,10,000 个基点是 100%)。从而得出以下公式:
// slippageBp is basis points, so 9900 means we tolerate a 1% slippage
sharesToCreate / totalShares >= slippageBp / 10_000 * msg.value / address(this).balance
为了消除可能会向下取整为 0 的分数,我们将不等式两边同时乘以 _totalShares * 10_000 * address(this).balance。这使我们得到:
sharesToCreate * 10_000 * address(this).balance >= slippageBp * msg.value * _totalShares
因此,我们的 mint 函数可以更新如下:
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 优化的空间,但为了简单起见,我们未在此处展示这种优化。
totalSupply()
如前所述,rebasing token 的总供应量就是池子中所持有的 ETH 数量:
function totalSupply() public view returns (uint256) {
return address(this).balance;
}
将代币数量(余额)转换为份额
在现阶段引入一个辅助函数 _amountToShares 将很有用。它是 balanceOf() 所使用公式的反函数。假设某个用户希望销毁(或转账)他们的全部余额。这对应于多少份额呢?
为了计算这个问题,我们针对 _shareBalance[user] 求解 balanceOf 等式:
在两边同时乘以 并除以 之后,我们得到:
因此,要将余额转换为份额,我们使用以下函数:
function _amountToShares(uint256 amount) internal view returns (uint256) {
if (address(this).balance == 0) {
return 0;
}
return amount * _totalShares / address(this).balance;
}
现在,当用户指定他们想要销毁的底层代币(在我们的例子中是 ETH)的“数量(amount)”时,我们可以直接将其转换为份额。
burn()
burn 接收的参数是他们想要销毁的余额,而不是份额。在销毁过程中,我们将销毁的数量转换为份额数量,然后从用户的份额和 totalShares 中扣除。
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);
}
再次强调,rebasing token 的余额代表他们可以提取的 ETH 数量。因此,amount 参数确切地表示在 burn() 结束时转移给他们的 ETH 数量。
没有必要在扣除之前检查用户的余额,因为 Solidity(0.8.0 或更高版本)在出现下溢(underflow)时会自动回滚(revert)。
我们将在后面的部分再次探讨 _spendAllowanceOrBlock() 函数。
添加 require(shares > 0, "zero shares"); 是为了防止 shares 向下取整为 0 时 Ether 被转出合约。回想一下,_amountToShares 的计算逻辑为 amount * _totalShares / address(this).balance;。如果 amount * _totalShares 小于 address(this).balance,那么 shares 就会向下取整为 0。而且 _shareBalance[from] -= shares; 和 _totalShares -= shares; 都不因为下溢而发生回滚,因此如果没有这个 require 语句,调用者就能从合约中凭空提取出 amount。
transfer() 和 transferFrom()
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。
通常来说,这是 rebasing tokens 需要注意的一个问题——详情可见 Lido 文档中关于此问题的说明以及 stETH 是如何处理的。
burn 中的舍入问题
作为一个推论,用户有可能会销毁他们的全部余额,但却仍遗留下一小部分余额,这是由于计算得出的需要销毁的份额相比于他们实际拥有的份额进行了向下取整。因此,我们不应假设当整个余额被销毁时,份额余额会自动归零。
Allowance 和 Approve
对于 rebasing tokens,并没有实现 allowance 和 approve 的“绝对正确”方式,因为 rebasing ERC-20 代币并没有统一的标准来规定它们的行为方式。
然而,大多数 rebasing tokens 使用了类似于普通 ERC-20 代币的 allowance 机制——但 allowance 不会进行 rebase。
这种机制的缺点是:如果 Alice 向 Bob 授权了她的全部余额,但在 Bob 从 Alice 的账户转账之前发生了一次 rebase,那么 Bob 就无法提取她的全部余额。
Compound Finance 使用的 rebasing token 通过只允许“全有或全无(all or nothing)”的授权来解决这个问题。在调用 approve 时,金额只能是 0 或 type(uint256).max——但这可能会破坏与那些指定了打算转账的确切 allowance 数额的协议的集成。另一方面,AAVE 和 stETH (Lido) 的 rebasing token 在 allowance 和 approval 上的行为与普通的 ERC-20 一样,并不会对 rebasing 做出修正。
因此,我们代币的授权逻辑与 OpenZeppelin 的实现非常相似。我们实现了 _spendAllowanceOrBlock() 函数,顾名思义,它会扣除 spender 的 allowance,并在 allowance 不足时产生回滚(revert)。在我们的实现中,如果 msg.sender == spender 我们不扣除 allowance;如果 allowance 是 type(uint256).max 我们也不扣除 allowance。
我们将在下一节展示包含它的完整实现。
一个完整的 Rebasing ERC-20
// 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()。
一些最后的注意事项
如果在有人已经铸造了 rebasing token 之后,又有另一个人将 ETH 转入合约,那么已铸造者的余额将会向上 rebase。
但是,如果在任何人进行铸造之前,有人先将 ETH 转入合约,那么第一个铸造者将获得该 ETH 的控制权,由于他们拥有所有未偿还份额,他们的余额将等于池子中的所有 ETH。
出于节省 gas 的考虑,一些协议会缓存 ERC-20 代币的余额——但这可能会因为代币发生 rebase 而导致其逻辑被破坏。
许多协议并不像上面我们的例子那样自动进行 rebase。相反,它们会每天进行 rebase 或者采用其他的周期性间隔。如果资产没有保存在合约中(例如被质押在以太坊验证器中),或者资产价值依赖于预言机(oracles),采用这种方式可能是必然的选择。
在与 rebasing 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 代币。Ampleforth 使用 rebase 代币将其价值动态锚定到另一种资产上。为了增加代币的价值,它会向下 rebase(使其变得更为稀缺),而在需要降低代币价值时,它会向上 rebase 以制造通货膨胀。
我们要感谢来自 Pashov Audit Group 的 MerlinBoii 和 deadrosesxyz 对本文早期版本提出的修改建议。我们还要感谢 ChainLight 审计了本文末尾的参考合约,并发现了其早期实现中的一个严重漏洞(审计报告)。