Ethereum: de historia a labs
Artículo 18 de 48
38%
completado al llegar aquí
Sistemas distribuidosNota de investigación13 min de lectura
ERC-4626: vaults, shares y contabilidad peligrosa
ERC-4626 por dentro: assets y shares, deposit, mint, withdraw y redeem, redondeo a favor del vault y el ataque de inflación sobre un vault vacío.
Ruta Ethereum: 0/48
Prerequisitos recomendados
Si alguno de estos conceptos todavía está medio nebuloso, empieza por aquí. Te ahorra tropiezos y un par de cejas fruncidas.
Un fondo de inversión no te devuelve "tus" billetes: te da participaciones, y cada participación vale una fracción de lo que el fondo tenga ese día. Si el fondo gana, tus participaciones valen más sin que cambie su número. ERC-4626 lleva esa idea a Ethereum. Depositas un token ERC-20, recibes participaciones del vault y, al salir, las participaciones se convierten de nuevo en tokens al tipo de cambio del momento.
Un vault (bóveda) es un contrato que custodia un único token ERC-20, llamado asset, y emite su propio token ERC-20, llamado share, que representa una fracción de lo custodiado [1]. Antes del estándar, cada protocolo de préstamos o agregador de rendimiento tenía su propia interfaz y los integradores escribían un adaptador por protocolo. ERC-4626 fija esa interfaz; la estrategia que genera rendimiento queda deliberadamente fuera.
Cinco comprobaciones sobre un vault que no escribiste
0 de 5 revisados
Brújula de lectura
Actor, evidencia y estado
Actor
Depositante, receptor y ownerQuien paga, quien recibe las shares y de quién se queman al salir pueden ser cuentas distintas.
Evidencia
Eventos Deposit y WithdrawRegistran assets y shares de cada operación, es decir, el tipo de cambio aplicado.
Estado
totalAssets y totalSupplySu cociente es el precio de una share; todo el riesgo contable vive ahí.
Preguntas guía
- Si alguien envía tokens directamente al vault, ¿quién se queda con ese valor?
- ¿Por qué
depositredondea hacia abajo yminthacia arriba? - ¿Qué diferencia hay entre
convertToSharesypreviewDeposit? - ¿Cómo puede un depositante perder todo su depósito sin que el contrato tenga un bug evidente?
Assets, shares y un tipo de cambio que nadie fija a mano
Todo vault ERC-4626 es a la vez un token ERC-20: balanceOf, transfer y totalSupply operan sobre las shares [1]. asset() devuelve la dirección del token custodiado y totalAssets() cuánto gestiona el vault, rendimientos incluidos. El precio de una share no se guarda en ningún slot: se deduce de esos dos números.
La guía de OpenZeppelin lo ilustra así: si un vault tiene 100 tokens para respaldar 200 shares, cada share vale 0,5 tokens; si tiene 200 tokens para 100 shares, cada share vale 2 [2]. Cuando la estrategia gana, totalAssets sube con el mismo número de shares y cada share vale más. Nadie actualiza un precio; el precio es una consecuencia.
Esa elegancia tiene un costo: cualquier cosa que cambie totalAssets sin emitir shares cambia el precio para todos. Un rendimiento legítimo lo hace, pero también una pérdida, una comisión o alguien que envía tokens directamente al contrato sin llamar deposit. Recordar esta frase hace evidente el ataque de la última sección.
Cuatro entradas y salidas: deposit, mint, withdraw y redeem
Hay dos formas de entrar y dos de salir, según qué cantidad fija el usuario. deposit(assets, receiver) fija cuántos tokens entregas y calcula cuántas shares recibes. mint(shares, receiver) fija cuántas shares quieres y calcula cuántos tokens pagas. Para salir, withdraw(assets, receiver, owner) fija cuántos tokens recibes y redeem(shares, receiver, owner) fija cuántas shares quemas.
Cada operación tiene tres funciones de consulta. convertToShares y convertToAssets dan la estimación "ideal" para un usuario promedio, sin comisiones ni condiciones de la cadena. previewDeposit, previewMint, previewWithdraw y previewRedeem simulan la operación en el bloque actual, comisiones incluidas. maxDeposit y sus pares dicen cuánto se aceptaría sin revertir. La regla para integradores es directa: para montos exactos, usa la función preview de la operación que vas a ejecutar.
Las entradas emiten Deposit(sender, owner, assets, shares) y las salidas Withdraw(sender, receiver, owner, assets, shares). Como cada evento lleva ambos números, el tipo de cambio aplicado a cada operación queda registrado en los logs. El estándar también exige que deposit y mint funcionen con el flujo approve y transferFrom de ERC-20, así que todo lo dicho sobre allowances en el capítulo anterior aplica aquí.
Invariante de redondeo: siempre a favor del vault
Con enteros, casi ninguna conversión es exacta. El EIP fija hacia qué lado redondear: cuando el vault calcula cuántas shares emitir por unos assets, o cuántos assets entregar por unas shares, redondea hacia abajo. Cuando calcula cuántas shares debe entregar el usuario por unos assets, o cuántos assets debe pagar por unas shares, redondea hacia arriba [1]. Las funciones convertTo* siempre redondean hacia abajo, para que todas las implementaciones sean consistentes.
La razón es un invariante: ninguna secuencia de entradas y salidas debe permitir sacar del vault más valor del que se puso. Si deposit redondeara hacia arriba, alguien podría depositar montos diminutos una y otra vez y ganar una fracción de share en cada vuelta, pagada por los demás depositantes. Redondear a favor del vault convierte el error de cada operación en una pequeña donación al conjunto, nunca en una extracción.
El precio de ese invariante lo paga el depositante cuando recibe pocas shares. OpenZeppelin lo cuantifica: si tu depósito equivale a mil millones de shares, el redondeo te cuesta como mucho una parte en mil millones; si equivale a 10 shares, puedes perder hasta un 10 %; si equivale a menos de una share, recibes cero y tu depósito se convierte en una donación [2]. Esa última frase es el punto de partida del ataque.
Vector de ataque: inflar el precio de un vault vacío
Supón un vault ingenuo que calcula shares = assets * totalSupply / totalAssets y que acaba de desplegarse vacío. El atacante deposita la unidad mínima del token y recibe 1 share. Después transfiere 10.000 tokens directamente al contrato, sin deposit. Ahora hay una sola share y más de 10.000 tokens: esa share vale todo. Cuando la víctima deposita 10.000 tokens, recibe floor(10.000 · 1 / (10.000 + ε)), es decir, 0 shares. El atacante redime su única share y se lleva ambos depósitos.
OpenZeppelin formaliza la condición: con un depósito inicial a0, una donación a1 y un depósito víctima u, la víctima recibe cero shares si u < 1 + a1 / a0 [2]. Con a0 = 1, basta donar tanto como la víctima va a depositar. En la práctica el atacante observa la transacción de la víctima en el mempool y coloca las suyas antes, igual que en la carrera de approve.
Ataque de inflación sobre un vault vacío, con y sin offset virtual
Cómo leer esta figura
Tabla de dos filas y cuatro columnas. Columnas: el atacante deposita una unidad mínima, el atacante dona 10.000 tokens directamente al vault, la víctima deposita 10.000 tokens con deposit, y resultado. Fila sin defensa: el atacante recibe 1 share con assets y shares igual a 1; tras la donación 1 share vale unos 10.000 tokens; la víctima recibe floor de 10 elevado a 10 por 1 dividido entre 10 elevado a 10 más 1, que es 0 shares; resultado, la víctima pierde 10.000 y el atacante gana 10.000. Fila con offset delta igual a 6: el atacante recibe 10 elevado a 6 shares, con 1 asset y 10 elevado a 6 shares virtuales; la donación se reparte con las shares virtuales; la víctima recibe 1.999.999 shares; resultado, la víctima pierde aproximadamente 0,0025 tokens y el atacante pierde aproximadamente 5.000. Nota: token de 6 decimales y cifras calculadas con la fórmula de conversión de OpenZeppelin y redondeo hacia abajo.
La defensa de OpenZeppelin, disponible desde la versión 4.9, combina dos ideas: representar las shares con más decimales que el asset (un offset δ) y sumar shares y assets virtuales a la fórmula [3]. En la versión 5 la conversión es shares = assets · (totalSupply + 10^δ) / (totalAssets + 1). Las shares virtuales no tienen dueño, así que capturan una parte de cualquier donación. Según la documentación, incluso con δ = 0 el ataque deja de ser rentable, y con un offset mayor cuesta órdenes de magnitud más de lo que puede robar.
Para un token de 6 decimales, un cálculo con esa fórmula da estas cifras ilustrativas. Sin defensa, la víctima de 10.000 tokens pierde todo y el atacante gana 10.000. Con δ = 6, la víctima recibe 1.999.999 shares y pierde unos 0,0025 tokens por redondeo, mientras el atacante pierde cerca de 5.000. Activarlo es una línea:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
contract VaultConOffset is ERC4626 {
constructor(IERC20 asset_)
ERC20("Vault Token", "vTKN")
ERC4626(asset_)
{}
// shares con 6 decimales más que el asset: sube el costo de inflar el precio
function _decimalsOffset() internal pure override returns (uint8) {
return 6;
}
}El offset tiene costos. En la implementación de OpenZeppelin, decimals() del vault pasa a ser los decimales del asset más el offset, aunque el EIP recomienda imitar los del asset. Las shares virtuales también capturan una pequeña parte del rendimiento y reparten distinto las pérdidas entre quienes salen primero y quienes salen al final. Otra defensa complementaria es que quien despliega el vault haga un primer depósito no trivial antes de abrirlo al público [3].
Riesgo operativo: el estándar no garantiza la implementación
El EIP es explícito sobre sus límites. Un vault malicioso puede cumplir la interfaz sin cumplir la especificación, y recomienda revisar la implementación antes de integrar [1]. También advierte que las funciones preview reflejan el estado actual y, por eso, se pueden manipular: no deben usarse como oráculo de precio. totalAssets y convertTo* son estimaciones para mostrar, no promesas de lo que recibirás.
Para cuentas externas el EIP sugiere funciones adicionales con tolerancia a slippage, porque una cartera no puede revertir si el resultado exacto no llega. OpenZeppelin recomienda lo mismo desde el lado del usuario: verificar que la cantidad recibida es la esperada, por ejemplo con un router que lo compruebe [3]. En el código propio esto se traduce en un parámetro de mínimo aceptable y un revert si no se alcanza.
El último riesgo es de composición. Las shares son un ERC-20, así que otros protocolos las aceptan como colateral y les ponen precio con convertToAssets. Si alguien puede mover ese precio con una donación o una pérdida súbita, el riesgo salta del vault al protocolo que lo usa. Por eso un vault se audita junto con quien lo integra, no por separado.
Conexiones dentro de la serie
- Antes: ERC-20: simple por fuera, tramposo por dentro explica el flujo approve y transferFrom que usan
depositymint. - También útil: Storage layout: donde Solidity cobra la factura muestra dónde viven
totalSupplyy los saldos de shares. - Después: Proxies y upgrades: cambiar el motor con el auto andando trata los vaults que cambian de implementación.
- Más adelante: Oráculos: datos externos con dientes y Lending y liquidaciones muestran qué pasa cuando el precio de una share se usa como colateral.
Bibliografía académica
- [1]
J. Santoro, t11s, J. Jadeja, A. Cuesta Cañada and Señor Doggo, "ERC-4626: Tokenized Vaults," Ethereum Improvement Proposals, no. 4626, Dec. 2021.
Definiciones de asset y share, funciones deposit/mint/withdraw/redeem, convert/preview/max, eventos Deposit y Withdraw, reglas de redondeo y consideraciones de seguridad.
- [2]
OpenZeppelin, "ERC-4626," OpenZeppelin Contracts 5.x Documentation.
Tipo de cambio como pendiente assets/shares, pérdidas por redondeo, ataque de inflación con su álgebra y defensa con offset virtual.
- [3]
OpenZeppelin, "ERC 20 API Reference: ERC4626," OpenZeppelin Contracts 5.x Documentation.
Advertencia sobre vaults vacíos, assets y shares virtuales desde v4.9, _decimalsOffset y su efecto en la rentabilidad del ataque.
Rutas vecinas
Si este tema te abrió hambre, sigue por aquí.
Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.
Series relacionadas
HotStuff: de cero a estado del arte
Ruta progresiva para entender consenso BFT moderno: quórums, sincronía parcial, locks, taxonomía one/two/three-chain, QC/highQC, multipipeline, view-change, responsiveness, AggQC, Fast-HotStuff, sync y frontera DAG-BFT.
Substrate: del runtime al consenso propio
Serie de 20 capítulos, de neófito a experto, sobre Substrate (Polkadot SDK): runtime en Wasm, FRAME, extrinsics, estado, pesos, actualizaciones sin fork, Aura, BABE, GRANDPA, sincronización, parachains, XCM, Sassafras, BEEFY y cómo conectar un consenso BFT propio a un nodo.
Ruta práctica: activos digitales en la operación
Nueve módulos, de menor a mayor dependencia, para decidir cómo usar custodia, wallets, llaves, firmas multifirma y MPC, AML/KYT y stablecoins con Fireblocks, BitGo, Kaleido, Circle y Tether.
Labs para convertir lectura en práctica
Prueba de trabajo y reajuste de dificultad
Laboratorio determinista para medir el costo de la prueba de trabajo y ver cómo el reajuste por altura de bloque estabiliza el tiempo entre bloques.
Consensus benchmark
Banco reproducible para comparar consenso BFT y documentar los trade-offs de HotStuff-2.
Ethereum local: Anvil, wallet y primera transacción
Laboratorio base para levantar una red local, crear cuentas, firmar transacciones y observar receipts sin gastar ETH real.
RPC y Cast: leer Ethereum con lupa
Ejercicios con JSON-RPC y Cast para consultar bloques, nonces, gas, receipts, logs y errores reproducibles.
Sigue leyendo
Ideas conectadas con este artículo
Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.
Oráculos: datos externos con dientes
Price feeds, stale data, TWAP, manipulación, dependencia de oráculos y diseño defensivo.
AMMs: x*y=k sin vender humo matemático
Automated market makers, constant product, pools, LP shares, slippage, fees y riesgos básicos.
Uniswap v3: liquidez concentrada y ticks
Rangos, ticks, capital efficiency, posiciones LP y nuevos riesgos de liquidez concentrada.
Lending y liquidaciones: cuando el colateral respira mal
Collateral, borrow, health factor, liquidations, oracle dependency y diseño básico de mercados lending.