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

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 . 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.

Antes de integrar

Cinco comprobaciones sobre un vault que no escribiste

0 de 5 revisados

El estándar fija la interfaz, no la honestidad ni la solidez de la implementación.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

Depositante, receptor y owner

Quien paga, quien recibe las shares y de quién se queman al salir pueden ser cuentas distintas.

Evidencia

Eventos Deposit y Withdraw

Registran assets y shares de cada operación, es decir, el tipo de cambio aplicado.

Estado

totalAssets y totalSupply

Su 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é deposit redondea hacia abajo y mint hacia arriba?
  • ¿Qué diferencia hay entre convertToShares y previewDeposit?
  • ¿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 . 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 . 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 . 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 . 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 . 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.

Figura 1

Ataque de inflación sobre un vault vacío, con y sin offset virtual

Fuentes:

[2][3]
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.

Visual original de TrautsLab. Mismo ataque en dos vaults: el ingenuo entrega cero shares a la víctima; el de OpenZeppelin con offset 6 hace que la donación la absorban las shares virtuales.

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 . 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:

solidity
// 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 .

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 . 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 . 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

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [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. [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. [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.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

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.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.