Un “token rebase” (a veces “token de rebasing”) es un token ERC-20 donde el suministro total (total supply) y los balances de los poseedores del token pueden cambiar sin realizar transferencias, acuñar (minting) o quemar (burning).
Los protocolos DeFi suelen utilizar tokens de rebasing para rastrear la cantidad de un activo que deben a un depositante, incluyendo las ganancias que el protocolo haya generado. Por ejemplo, si el protocolo le debe a un depositante 10 ETH (incluyendo ganancias), el balance ERC-20 del depositante para el token de rebasing sería 10e18. Si el valor de su depósito aumentara a 11 ETH, su balance haría un “rebase” a 11e18.
Este artículo explica cómo programar un token de rebasing, así como la lógica detrás del código.
También cubrimos los posibles problemas de seguridad que pueden surgir al crear un token de rebasing.
Ejemplo de transacciones con tokens rebase
Considera el siguiente ejemplo que ilustra los tokens de rebasing:
- Alice deposita 100 ETH en un pool. El pool le acuña 100 rbLP (rebasing LP token).
- Alice podría quemar los 100 rbLP y recuperar sus 100 ETH. El balance de su rbLP es la cantidad de ETH que puede canjear del pool.
- Pero supongamos que el pool obtiene una ganancia del 10%, por ejemplo, de tarifas de préstamos. Su balance de 100 rbLP hará un “rebase” automático a 110 rbLP.
- Es decir, si llamamos a
rbLP.balanceOf(alice)justo cuando ella deposita, devolvería 100 (con 18 decimales). - Después de generar ganancias,
rbLP.balanceOf(alice)devolvería 110.
- Es decir, si llamamos a
- Ahora Bob deposita 100 ETH en el pool, después de que el pool obtuviera ganancias. El pool le acuñaría 100 rbLP. Alice, sin embargo, tiene 110 rbLP ya que ella estaba proveyendo liquidez antes de que el pool obtuviera la ganancia del 10%.
Un token rebase intenta mantener el suministro total del token rebase igual a la cantidad total de ETH retenida por el pool. En realidad, puede haber un poco más de ETH que el suministro total de tokens rebase debido a errores de redondeo — discutiremos esto más adelante.
El balance del token rbLP que tiene un usuario es la cantidad de ETH que puede canjear del pool. Por lo tanto, el balance de un usuario puede interpretarse como su “parte” (share) del suministro total del token de rebasing (o de manera equivalente, la cantidad de ETH retenida por el pool).
Nos referiremos al activo depositado como ETH por el resto de este artículo, pero por supuesto, podría ser algún otro token ERC-20.
Diseñando un token de rebasing
Crearemos un token ERC-20 de rebasing. El suministro total del contrato del token ERC-20 es la cantidad de Ether en posesión del token (lo que significa que nuestro token de rebasing tiene 18 decimales). A veces nos referiremos al “token” como el “pool” de manera intercambiable. Uno puede pensar en esto como un pool que implementa el estándar ERC-20 (pero con rebasing) para rastrear a quién se le debe cuántos ETH.
Este diseño está fuertemente inspirado en el token Lido stETH.
balanceOf()
En un ERC-20 tradicional, el balance de un usuario es simplemente un número asociado con la dirección en el mapping(address => uint256).
En un token ERC-20 de rebasing, el valor que guarda el mapping representa la propiedad fraccional del usuario sobre el pool. Lo mejor es pensar que el mapping contiene “shares” (participaciones).
mapping(address => uint256) internal _shareBalance;
La fracción del suministro total puede calcularse como _shareBalance[user] / _totalShares, donde _totalShares es la suma de los shares de todos los usuarios.
Supongamos que Alice posee el 70% del ETH en el pool y Bob posee el 30% del ETH en el pool. Una distribución válida de shares podría ser:
- Alice: 70 shares
- Bob: 30 shares
Pero solo nos interesan los shares como una proporción de propiedad. La siguiente distribución de shares sería válida en el mismo escenario:
- Alice: 35 shares
- Bob: 15 shares
El balanceOf del token rebase representa la cantidad de ETH que el usuario puede canjear. La cantidad de ETH que puede canjear un cierto número de shares es:
Utilizando el segundo ejemplo donde Alice tiene 35 shares y Bob tiene 15 shares, podemos ver que Alice puede canjear el 70% del ETH en el pool:
Traduciendo la fórmula a Solidity obtenemos:
function balanceOf(address account) public view returns (uint256) {
if (_totalShares == 0) {
return 0;
}
return _shareBalance[account] * address(this).balance / _totalShares;
}
La variable _totalShares se actualiza durante mint() y burn() cuando se añade o se retira ETH del pool.
mint()
Durante mint(), el depositante añade Ether al pool y acuña una cantidad de shares que representa su porcentaje de propiedad sobre el porcentaje total de shares en circulación (la suma de todos los shares en existencia, o la suma de todos los _shareBalance[user] para todos los usuarios).
Si son el primer acuñador (minter), entonces la cantidad de shares acuñados es simplemente msg.value. De lo contrario, deben mantener la proporción:
Esto se puede pensar como decir que “la cantidad de balance que puede canjear un share no cambia por un mint”.
Para despejar sharesToCreate, vamos a acortar las variables de la siguiente manera:
Donde:
- son los shares previos
- es el balance previo
- son los shares a crear y
- es
msg.value.
Podemos extraer con la siguiente álgebra:
Por lo tanto, sharesToCreate = sharesPrevious * msg.value / balancePrevious. Sin embargo, balancePrevious no es algo que almacenemos, pero puede calcularse como address(this).balance - msg.value. Así, nuestro código para mint() es el siguiente (¡el siguiente código aún no es completamente seguro!):
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);
}
Ten en cuenta que address(this).balance y totalShares aumentan en la misma cantidad porcentual. Por lo tanto, la proporción
permanece inalterada en su mayoría durante un mint. Esto se debe a que el cálculo de sharesToCreate implica una división, y el número de shares creados para el minter puede ser ligeramente menor de lo que debería ser, lo que significa que su porcentaje de propiedad está ligeramente subrepresentado. Esto significa que otros usuarios podrían experimentar un ligero incremento en su porcentaje de propiedad.
Sin embargo, si alguien transfiere ETH directamente al contrato, o el contrato obtiene ganancias en ETH (es decir, no porque alguien haya hecho un mint), entonces el balance aumentará, pero los _totalShares no lo harán. Esto incrementaría el valor de la proporción en la fórmula anterior, causando que el balance haga un rebase hacia arriba.
Ten en cuenta que un atacante puede aumentar temporalmente su balance utilizando un flashloan para acuñar el token de rebasing. Por lo tanto, ninguna lógica crítica de negocio debe depender ciegamente de balanceOf() o totalSupply().
También vale la pena señalar que en la fórmula:
sharesToCreate = msg.value * _totalShares / (address(this).balance - msg.value);
es posible que sharesToCreate se redondee hacia abajo a cero si
- el pool tiene un balance de ETH significativo
- _totalShares es relativamente bajo (es decir, el protocolo ha obtenido muchas ganancias)
msg.valuees pequeño
Dado que es posible que los shares se redondeen hacia abajo a cero, la implementación actual es vulnerable a un ataque de depósito pequeño (small deposit attack).
Específicamente:
- El atacante acuña 1 wei
- El atacante se adelanta (frontruns) al mint de 1 ether de la víctima donando 100 ether al pool.
En el paso 2, los sharesToCreate se calcularán como:
Esto se redondeará hacia abajo a cero ya que el denominador es mayor que el numerador. Ahora la víctima ha depositado 1 ether, pero no se le acuñó nada. El atacante posee todos los shares y, por ende, ha tomado el control del depósito de la víctima.
Por lo tanto, nuestro token rebase debe implementar algún tipo de protección contra el deslizamiento (slippage).
Podríamos crear un parámetro “minimumShares”, pero esto expone la abstracción de los shares al usuario. En otras palabras, los integradores ahora tendrían que pensar en los “shares” como un valor separado de los “balances”.
Una medida de seguridad alternativa que no requiere conocimiento de los shares es verificar que la proporción de sharesToCreate / _totalShares esté cerca de msg.value / address(this).balance. Si sharesToCreate se redondeó hacia abajo demasiado, entonces la proporción sharesToCreate / _totalShares será mucho menor que la proporción del ether depositado en relación con el balance total.
Ya que sharesToCreate se redondea ligeramente hacia abajo, verificamos que:
sharesToCreate / _totalShares >= slippage * msg.value / address(this).balance
donde slippage es un valor como 0.999 si el deslizamiento deseado es del 0.1%. Por supuesto, no podemos expresar 0.999 en Solidity, así que podríamos usar puntos básicos (basis points) en su lugar (1 punto básico es 0.01%, 10,000 puntos básicos es 100%). Esto nos lleva a la siguiente fórmula:
// slippageBp is basis points, so 9900 means we tolerate a 1% slippage
sharesToCreate / totalShares >= slippageBp / 10_000 * msg.value / address(this).balance
Para eliminar las fracciones que se redondearán hacia abajo a cero, multiplicamos ambos lados de la desigualdad por _totalShares * 10_000 * address(this).balance. Esto nos da
sharesToCreate * 10_000 * address(this).balance >= slippageBp * msg.value * _totalShares
Por lo tanto, nuestra función mint puede actualizarse de la siguiente manera:
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);
}
Hay espacio para optimizar el gas si no se lee _totalShares y _shareBalance[to] desde el almacenamiento (storage) justo después de escribirlos, pero no mostraremos esta optimización por simplicidad.
totalSupply()
Como se mencionó anteriormente, el suministro total del token de rebasing es el ETH en posesión del pool:
function totalSupply() public view returns (uint256) {
return address(this).balance;
}
Convirtiendo una cantidad de tokens (balance) a shares
En esta etapa, es útil introducir una función auxiliar amountToShares. Es la función inversa de la fórmula que utiliza balanceOf(). Supongamos que un usuario quiere quemar (o transferir) todo su balance. ¿A cuántos shares corresponde eso?
Para calcular esto, despejamos _shareBalance[user] en la ecuación de balanceOf:
Después de multiplicar ambos lados por y dividir entre , obtenemos:
Así, para convertir un balance a shares, usamos la siguiente función:
function _amountToShares(uint256 amount) internal view returns (uint256) {
if (address(this).balance == 0) {
return 0;
}
return amount * _totalShares / address(this).balance;
}
Ahora, cuando un usuario especifica un “amount” (cantidad) de token subyacente (en nuestro caso, ETH) que quiere quemar, podemos convertirlo directamente a shares.
burn()
El argumento para burn es el balance que buscan quemar, no los shares. Durante un burn, convertimos la cantidad quemada a la cantidad de shares, y luego deducimos eso de los shares del usuario y de 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);
}
Nuevamente, el balance de un token de rebasing es la cantidad de ETH que pueden retirar. Por lo tanto, el parámetro amount es precisamente la cantidad de ETH transferida a ellos al final del burn().
No es necesario verificar el balance del usuario antes de deducirlo, ya que Solidity (versión 0.8.0 o posterior) revierte en caso de underflow.
Revisaremos la función _spendAllowanceOrBlock() en una sección posterior.
El require(shares > 0, "zero shares"); es para evitar que el Ether sea transferido fuera del contrato si shares se redondea hacia abajo a 0. Recuerda que _amountToShares se calcula como amount * _totalShares / address(this).balance;. Si amount * _totalShares es menor que address(this).balance, entonces shares se redondea a 0. Ni _shareBalance[from] -= shares; ni _totalShares -= shares; revertirán debido a underflow, por lo que el caller podría retirar amount del contrato sin esa declaración require.
transfer() and transferFrom()
Transfer y transferFrom son similares a burn, excepto que en lugar de destruir los shares y enviar el ETH, los shares se acreditan a otra cuenta y no se transfiere 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;
}
Debido a que amountToShares realiza el cálculo amount * _totalShares / address(this).balance, la cantidad de shares transferidos podría redondearse hacia abajo.
Debido a que la división redondea hacia abajo, esto significa que el balance recibido por to podría ser muy levemente menor que amount.
Este es un problema que hay que tener en cuenta en general para los tokens de rebasing — consulta la documentación de Lido sobre este problema para ver cómo lo maneja stETH.
Problema de redondeo en burn
Como corolario, es posible que un usuario queme todo su balance pero se quede con un balance pequeño porque el número calculado de shares a quemar se redondeó hacia abajo desde el número real de shares que posee. Por lo tanto, no debemos asumir que el balance de shares llega a cero cuando se quema la totalidad del balance.
Allowance y Approve
No hay una forma “correcta” de implementar allowance (asignación) y approve (aprobación) para tokens de rebasing, ya que los tokens ERC-20 de rebasing no tienen un estándar que dicte cómo deben comportarse.
Sin embargo, la mayoría de los tokens de rebasing utilizan un mecanismo de allowance que es similar a un token ERC-20 regular, pero el allowance no hace rebase.
La desventaja de este mecanismo es que si Alice aprueba a Bob para la totalidad de su balance, pero hay un rebase antes de que Bob transfiera desde la cuenta de Alice, entonces Bob no podrá retirar todo su balance.
El token de rebasing que usa Compound Finance resuelve esto permitiendo solo aprobaciones de “todo o nada”. Al llamar a approve, la cantidad solo puede ser 0 o type(uint256).max — pero esto puede romper integraciones con protocolos que especifican un allowance para la cantidad que intentan transferir. Por otro lado, el allowance y la aprobación para los tokens de rebasing de AAVE y stETH (Lido) se comportan como un ERC-20 normal y no se corrigen por el rebasing.
Por lo tanto, la lógica de aprobación para nuestro token es muy similar a la de OpenZeppelin. Implementamos la función _spendAllowanceOrBlock() que, como su nombre sugiere, gasta el allowance del spender y revierte si el allowance no es suficiente. En nuestra implementación, no gastamos el allowance si msg.sender == spender y no deducimos el allowance si este es type(uint256).max.
Lo mostramos en la implementación completa en la siguiente sección.
Un ERC-20 de rebasing completo
// 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;
}
}
}
Ten en cuenta que name(), symbol() y decimals() no se han implementado en el código anterior.
Algunas notas finales
Si alguien transfiere ETH al contrato después de que alguien ya haya acuñado el token de rebasing, el balance del minter hará un rebase hacia arriba.
Sin embargo, si alguien transfiere ETH al contrato antes de que alguien haga un mint, entonces el primer minter obtendrá el control de ese ETH, y su balance será igual a todo el ETH en el pool, ya que poseerá todos los shares en circulación.
Algunos protocolos guardan en caché el balance de los tokens ERC-20 para mayor eficiencia de gas, pero esto podría romper su lógica si el token hace rebase.
Muchos protocolos no hacen rebase automáticamente como en nuestro ejemplo anterior. En su lugar, hacen rebase diariamente o en algún otro intervalo periódico. Esto puede ser necesario si el activo no se guarda en el contrato (por ejemplo, está en staking en validadores de Ethereum) o si el valor de los activos depende de oráculos.
Al interactuar con un token de rebasing, el amount especificado en la transferencia podría no ser igual al cambio en el balance debido al redondeo de los shares. Por lo tanto, es mejor usar la siguiente lógica para determinar la cantidad real depositada:
uint256 balanceBefore = token.balanceOf(address(this));
token.transferFrom(sender, address(this), amount);
uint256 trueTransferAmount = token.balanceOf(address(this)) - balanceBefore;
Ampleforth y el token sOHM de OlympusDao son otros dos tokens rebase notables. Ampleforth utiliza tokens rebase para fijar dinámicamente el valor del token rebase a otro activo. Para aumentar el valor del token, hace rebase hacia abajo (haciéndolo más escaso), y cuando necesita disminuir el valor del token, hace rebase hacia arriba para causar inflación.
Nos gustaría agradecer a MerlinBoii de Pashov Audit Group y a deadrosesxyz por sus sugerencias de revisión para versiones anteriores de este artículo. Nos gustaría agradecer a ChainLight por auditar el contrato de referencia al final del artículo e identificar una grave vulnerabilidad en una implementación anterior (informe de auditoría).