ERC4626 es un estándar de bóveda tokenizada que utiliza tokens ERC20 para representar las participaciones (shares) de algún otro activo.
Funciona de la siguiente manera: depositas un token ERC20 (token A) en el contrato ERC4626 y obtienes a cambio otro token ERC20, llamémoslo token S.
En este ejemplo, el token S representa tu participación sobre la totalidad del token A que posee el contrato (no el suministro total de A, sino únicamente el balance de A en el contrato ERC4626).
Más adelante, puedes devolver el token S al contrato de la bóveda (vault) y recuperar tu token A.
Si el balance del token A en la bóveda creció más rápido de lo que se produjo el token S, retirarás una cantidad proporcionalmente mayor del token A de la que depositaste.
Un contrato ERC4626 también es un token ERC20
Cuando un contrato ERC4626 te entrega un token ERC20 por el depósito inicial, te entrega el token S (un token compatible con ERC20). Este token ERC20 no es un contrato separado. Está implementado dentro del propio contrato ERC4626. De hecho, puedes ver que así es como OpenZeppelin define el contrato en Solidity:
abstract contract ERC4626 is ERC20, IERC4626 {
using Math for uint256;
IERC20 private immutable _asset;
uint8 private immutable _underlyingDecimals;
/**
* @dev Set the underlying asset contract. This must be an ERC20-compatible contract (ERC20 or ERC777).
*/
constructor(IERC20 asset_) {
(bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_);
_underlyingDecimals = success ? assetDecimals : 18;
_asset = asset_;
}
Declaración en Solidity de ERC4626
El ERC4626 extiende el contrato ERC20 y, durante la fase de construcción, toma como argumento el otro token ERC20 que los usuarios depositarán en él.
Por lo tanto, ERC4626 soporta todas las funciones y eventos que esperarías de un ERC20:
balanceOftransfertransferFromapproveallowance
Y así sucesivamente.
A este token se le denomina shares (participaciones) en un ERC4626. Se trata del propio contrato ERC4626.
Cuantas más participaciones poseas, más derechos tendrás sobre el asset (activo) subyacente (el otro token ERC20) que se deposita en él.
Cada contrato ERC4626 soporta un único activo. No puedes depositar múltiples tipos de tokens ERC20 en el contrato y recibir participaciones a cambio.
Motivación del ERC4626
Usemos un ejemplo real para entender la motivación detrás de su diseño.
Supongamos que todos somos dueños de una empresa, o de un pool de liquidez, que gana la moneda estable DAI periódicamente. La moneda estable DAI es el activo en este caso.
Una forma ineficiente en la que podríamos distribuir las ganancias sería enviando DAI a cada uno de los titulares de la empresa de forma prorrateada. Pero esto sería extremadamente costoso en términos de gas.
Del mismo modo, si tuviéramos que actualizar el balance de cada persona dentro de un contrato inteligente, también sería costoso.
En su lugar, así es como funcionaría el flujo de trabajo con el ERC4626.
Digamos que tú y nueve amigos se reúnen y cada uno deposita 10 DAI en la bóveda ERC4626 (100 DAI en total). Recibes a cambio una participación.
Hasta aquí todo bien. Ahora tu empresa gana 10 DAI más, por lo que el total de DAI dentro de la bóveda es ahora de 110 DAI.
Cuando intercambias tu participación para recuperar tu parte de los DAI, no obtienes 10 DAI de vuelta, sino 11.
Ahora hay 99 DAI en la bóveda, pero 9 personas entre las que repartirlo. Si cada una de ellas retirara sus fondos, obtendrían 11 DAI cada una.
Nota lo eficiente que resulta esto. Cuando alguien realiza un intercambio, en lugar de actualizar las participaciones de todos una por una, solo cambian el suministro total de participaciones y la cantidad de activos en el contrato.
El ERC4626 no tiene por qué usarse obligatoriamente de esta manera. Puedes tener una fórmula matemática arbitraria que determine la relación entre las participaciones y los activos. Por ejemplo, podrías establecer que cada vez que alguien retira el activo, también tiene que pagar algún tipo de impuesto que dependa del timestamp del bloque o algo similar.
El estándar ERC-4626 proporciona un medio eficiente en el uso de gas para ejecutar prácticas contables muy comunes en DeFi.
Participaciones del ERC4626
Naturalmente, los usuarios quieren saber qué activo utiliza el ERC4626 y cuántos posee el contrato, por lo que existen dos funciones de solidity en la especificación ERC4626 para ello.
function asset() returns (address)
La función asset devuelve la dirección del token subyacente utilizado para la bóveda. Si el activo subyacente fuera, por ejemplo, DAI, entonces la función devolvería la dirección del contrato ERC20 de DAI 0x6b175474e89094c44da98b954eedeac495271d0f.
function totalAssets() returns (uint256)
Llamar a la función totalAssets devolverá la cantidad total de activos “administrados” (poseídos) por la bóveda, es decir, el número de tokens ERC20 propiedad del contrato ERC4626. La implementación es bastante sencilla en OpenZeppelin:
/** @dev See {IERC4626-totalAssets}. */
function totalAssets() public view virtual override returns (uint256) {
return _asset.balanceOf(address(this));
}
Por supuesto, no existe ninguna función para obtener la dirección de las participaciones, porque esa es simplemente la dirección del propio contrato ERC4626.
Entregar activos, obtener participaciones: deposit() y mint()
Copiemos y peguemos las dos especificaciones para realizar este intercambio directamente desde el EIP.
// EIP: Mints a calculated number of vault shares to receiver by depositing an exact number of underlying asset tokens, specified by user.
function deposit(uint256 assets, address receiver) public virtual override returns (uint256)
// EIP: Mints exact number of vault shares to receiver, as specified by user, by calculating number of required shares of underlying asset.
function mint(uint256 shares, address receiver) public virtual override returns (uint256)
Según el EIP, el usuario deposita activos y recibe participaciones a cambio, así que, ¿cuál es la diferencia entre estas dos funciones?
- Con deposit(), tú especificas cuántos activos deseas ingresar, y la función calculará cuántas participaciones se te enviarán.
- Con mint(), tú especificas cuántas participaciones deseas, y la función calculará qué cantidad del activo ERC20 deberá transferirse desde tu cuenta.
Por supuesto, si no tienes suficientes activos para transferir al contrato, la transacción se revertirá.
El uint256 que se te devuelve es la cantidad de participaciones que obtienes a cambio.
El siguiente invariante siempre debería cumplirse:
// remember, erc4626 is also an erc20 token
uint256 sharesBalanceBefore = erc4626.balanceOf(address(this));
uint256 sharesReceived = erc4626.deposit(numAssets, address(this));
// strict equality checks in accounting are a big no no!
assert(erc4626.balanceOf(address(this)) >= sharesBalanceBefore + sharesReceived);
Anticipar cuántas participaciones obtendrás
Si estás usando web3.js, puedes realizar un staticcall a las funciones deposit o mint para predecir lo que sucederá. Sin embargo, si estás haciendo esto on-chain, tienes las siguientes dos funciones a tu disposición:
previewDepositpreviewMint
Al igual que sus contrapartes que modifican el estado, previewDeposit toma los activos como argumento y previewMint toma las participaciones como argumento.
Anticipar cuántas participaciones obtendrás en condiciones ideales
Por confuso que parezca, también existe una función view llamada convertToShares que toma los activos como argumento y devuelve la cantidad de participaciones que obtendrías en condiciones ideales (sin deslizamiento de precio o comisiones).
¿Por qué te importaría esta información ideal que no refleja el intercambio que vas a ejecutar?
La diferencia entre los resultados ideales y los reales te indica cuánto está impactando tu intercambio en el mercado y cómo la comisión depende del tamaño de la operación. Un contrato inteligente podría realizar una búsqueda binaria sobre la diferencia entre convertToShares y previewMint para encontrar el mejor tamaño de operación a ejecutar.
Devolver participaciones, recuperar activos
La inversa de deposit y mint son withdraw y redeem, respectivamente.
Con deposit, especificas los activos que deseas intercambiar y el contrato calcula cuántas participaciones obtienes.
Con mint, especificas cuántas participaciones deseas y el contrato calcula cuántos activos tomar de ti.
De manera similar, withdraw te permite especificar cuántos activos deseas tomar del contrato, y el contrato calcula cuántas de tus participaciones se deben quemar.
Con redeem, especificas cuántas participaciones deseas quemar, y el contrato calcula la cantidad de activos que se te devolverán.
Anticipar cuántas participaciones quemarás para recuperar activos
Los métodos view para withdraw y redeem son previewWithdraw y previewRedeem respectivamente.
El análogo idealizado de estas funciones es convertToAssets, que toma las participaciones como argumento y te indica cuántos activos obtendrás de vuelta, sin incluir comisiones ni deslizamiento (slippage).
Resumen de las funciones hasta ahora
| Función | Modifica el estado o View | Toma como argumento | Devuelve | Ideal o Real |
|---|---|---|---|---|
| deposit | modifica el estado | activos | participaciones | real |
| previewDeposit | view | activos | participaciones | real |
| withdraw | modifica el estado | activos | participaciones | real |
| previewWithdraw | view | activos | participaciones | real |
| convertToShares | view | activos | participaciones | ideal |
| mint | modifica el estado | participaciones | activos | real |
| previewMint | view | participaciones | activos | real |
| redeem | modifica el estado | participaciones | activos | real |
| previewRedeem | view | participaciones | activos | real |
| convertToAssets | view | participaciones | activos | ideal |
¿Qué pasa con el argumento address?
function mint(uint256 shares, address receiver) external returns (uint256 assets);
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
Las funciones mint, deposit, redeem y withdraw tienen un segundo argumento “receiver” para los casos en que la cuenta que recibe las participaciones o los activos del ERC4626 no es msg.sender. Esto significa que puedo depositar activos en la cuenta y especificar que el contrato ERC4626 te entregue a ti las participaciones.
Redeem y withdraw tienen un tercer argumento, “owner”, que permite a msg.sender quemar las participaciones del “owner” (propietario) mientras envía los activos al “receiver” (receptor, el segundo argumento) si tienen asignada la asignación (allowance) para hacerlo.
maxDeposit, maxMint, maxWithdraw, maxRedeem
Estas funciones toman argumentos idénticos a los de sus contrapartes que modifican el estado y devuelven la operación más grande que pueden ejecutar. Esto puede cambiar según la dirección (recuerda que acabamos de comentar que estas funciones toman direcciones como argumentos).
Eventos
El ERC4626 tiene solo dos eventos además de los eventos ERC20 que hereda: Deposit y Withdraw. Estos también se emiten si se llamaron a mint y redeem, ya que funcionalmente ocurrió lo mismo: se intercambiaron tokens.
Problemas con el deslizamiento (slippage)
Cualquier protocolo de intercambio de tokens se enfrenta al problema de que el usuario podría no recibir a cambio la cantidad de tokens que esperaba.
Por ejemplo, con los creadores de mercado automatizados (AMM), una operación grande podría agotar la liquidez y provocar que el precio se mueva sustancialmente.
Otro problema es que una transacción sufra un frontrunning o experimente un ataque sándwich. En los ejemplos anteriores, hemos asumido que el contrato ERC4626 mantiene una relación de uno a uno entre el activo y las participaciones independientemente del suministro, pero el estándar ERC4626 no dicta cómo debe funcionar el algoritmo de fijación de precios.
Por ejemplo, supongamos que hacemos que la cantidad de participaciones emitidas sea una función de la raíz cuadrada de los activos depositados. En ese caso, el primero que deposite obtendrá una mayor cantidad de participaciones. Esto podría animar a los traders oportunistas a adelantarse (frontrun) a las órdenes de depósito y obligar al siguiente comprador a pagar una mayor cantidad del activo por la misma cantidad de participaciones.
La defensa contra esto es simple: el contrato que interactúa con un ERC4626 debe medir la cantidad de participaciones que recibió durante un depósito (y los activos durante un retiro) y revertir la transacción si no recibe la cantidad esperada dentro de una cierta tolerancia de deslizamiento.
Este es un patrón de diseño estándar para lidiar con los problemas de deslizamiento. También sirve como defensa contra el problema descrito a continuación.
Ataque de inflación al ERC4626
Aunque ERC4626 es agnóstico respecto al algoritmo que traduce los precios a participaciones, la mayoría de las implementaciones utilizan una relación lineal. Si hay 10,000 activos y 100 participaciones, entonces 100 activos deberían equivaler a 1 participación.
Pero, ¿qué sucede si alguien envía 99 activos? Se redondeará hacia abajo a cero y obtendrá cero participaciones.
Por supuesto, nadie tiraría su dinero intencionadamente de esta manera. Sin embargo, un atacante puede adelantarse a una transacción donando activos a la bóveda.
Si un atacante dona dinero a la bóveda, una participación de repente vale más de lo que valía inicialmente. Si hay 10,000 activos en la bóveda que corresponden a 100 participaciones, y el atacante dona 20,000 activos, entonces de repente una participación pasa a valer 300 activos en lugar de 100. Cuando el intercambio de la víctima entrega activos para obtener participaciones, de repente obtiene muchas menos participaciones —posiblemente cero.
Existen tres defensas:
- Revertir la transacción si la cantidad recibida no está dentro de una tolerancia de deslizamiento (descrita anteriormente).
- El desplegador debe depositar suficientes activos en el pool de manera que llevar a cabo este ataque de inflación resulte demasiado costoso.
- Añadir “liquidez virtual” a la bóveda para que la fijación de precios se comporte como si el pool hubiera sido desplegado con suficientes activos.
Aquí está la implementación de liquidez virtual de OpenZeppelin:

Al calcular la cantidad de participaciones que recibe un depositante, el suministro total se infla artificialmente (a una tasa que el programador especifica en _decimalsOffset()).
Veamos un ejemplo paso a paso. A modo de recordatorio, esto es lo que significan las variables mencionadas arriba:
totalSupply()= número total de participaciones emitidastotalAssets()= el balance de activos mantenidos por el ERC4626- assets = la cantidad de activos que el usuario está depositando
La fórmula es:
shares_received = assets_deposited * totalSupply() / totalAssets();
Hay algunos detalles de implementación para redondear a favor del pool y sumar 1 a totalAssets() para asegurarnos de no dividir por cero si el pool está vacío.
Supongamos que tenemos los siguientes números:
assets_deposited = 1,000
totalSupply() = 1,000
totalAssets() = 999,999 (la fórmula suma 1, así que lo configuraremos de esta manera para que el número sea exacto)
En ese caso, las participaciones que obtendrá el usuario son , o exactamente 1.
Evidentemente, esto es muy frágil. Si el atacante se adelanta al depósito de 1,000 participaciones y deposita activos, la víctima no obtendrá nada a cambio, porque 1 millón dividido por un número mayor a 1 millón es cero en la división de enteros.
¿Cómo resuelve esto la liquidez virtual? Usando el código de la captura de pantalla anterior, configuraríamos _decimalsOffset() a 3, de modo que a totalSupply() se le sumen 1,000.
En la práctica, estamos haciendo que el numerador sea 1,000 veces mayor. Esto obliga al atacante a realizar una donación 1,000 veces mayor, lo que lo desincentiva a llevar a cabo el ataque.
Ejemplos reales de contabilidad de participaciones / activos
Las primeras versiones de Compound acuñaban lo que llamaban c-tokens para los usuarios que suministraban liquidez. Por ejemplo, si depositabas USDC, recibías a cambio un cUSDC independiente (Compound USDC). Cuando decidías dejar de prestar, devolvías tus cUSDC a Compound (donde se quemaban) y luego obtenías tu participación prorrateada del pool de préstamos de USDC.
Uniswap utilizaba tokens LP como “participaciones” para representar cuánta liquidez había aportado alguien a un pool (y cuánto podían retirar de forma prorrateada) al canjear los tokens LP por el activo subyacente.
Aprende más
Aprende temas más avanzados en nuestro bootcamp de blockchain.
Recursos adicionales
Autor original del EIP en Youtube
Implementación de OpenZeppelin
Implementación de Solmate
Publicado originalmente el 17 de feb. de 2023