Ethereum: de historia a labs

Artículo 19 de 48

40%

completado al llegar aquí

Sistemas distribuidosNota de investigación17 min de lectura

Proxies y upgrades: cambiar el motor con el auto andando

Cómo delegatecall separa código y storage, qué fijan ERC-1967 y ERC-1822, y por qué layout, inicializadores y admin deciden si un upgrade es seguro.

Ruta Ethereum: 0/48

Imagina que tu equipo publicó un contrato de depósitos y, tres semanas después, encuentra un bug. El bytecode desplegado en Ethereum no se puede editar. Si el contrato guarda el dinero de los usuarios, migrar a una dirección nueva obliga a cada usuario a moverse. El patrón proxy resuelve eso: una dirección estable guarda el estado y el saldo, y delega la ejecución a otro contrato, la implementación, cuya dirección se puede cambiar.

Esa flexibilidad tiene precio. Quien controla el cambio de implementación controla todo el código que se ejecuta sobre el dinero. Este capítulo explica el mecanismo, la regla de storage que no se puede romper y los controles que hacen que un upgrade sea una operación gobernada y no una puerta trasera.

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

Admin de upgrade

La cuenta o contrato autorizado a cambiar la implementación: owner, multisig o timelock.

Evidencia

Slot ERC-1967 y evento Upgraded

Cualquiera puede leer el slot de implementación y seguir el historial de eventos.

Estado

Storage del proxy

Los datos viven en el proxy; la implementación aporta solo código.

Preguntas guía

  • Cuando un usuario llama al proxy, ¿en qué storage escribe el código de la implementación?
  • ¿Por qué agregar una variable al principio de un contrato puede corromper balances existentes?
  • ¿Dónde vive la función de upgrade en un proxy transparente y dónde en uno UUPS?
  • ¿Qué pasa si nadie llama al inicializador de la implementación?
  • ¿Quién puede cambiar la lógica hoy, y cuánto tiempo tienen los usuarios para reaccionar?

Traza de ejecución: qué hace delegatecall dentro de un proxy

Empecemos por una llamada concreta: un usuario envía deposit() a la dirección del proxy. El proxy no tiene una función deposit, así que la llamada cae en su función fallback. Ese fallback copia el calldata y ejecuta DELEGATECALL hacia la implementación.

DELEGATECALL es una variante de llamada entre contratos. Ejecuta el código de la dirección destino, pero en el contexto del contrato que llama: el storage, la dirección actual y el balance siguen siendo los del proxy, y msg.sender y msg.value no cambian. Dicho de otra forma: la implementación presta su código, no su memoria.

El resultado práctico es que la implementación funciona como una biblioteca sin estado propio. Si la implementación escribe en su "slot 0", en realidad escribe en el slot 0 del proxy. Si el usuario llama directamente a la implementación, opera sobre otro storage, el de la implementación, que nadie usa.

Figura 1

Delegatecall: código de la implementación, storage del proxy

Fuentes:

[1][3]
Delegatecall: código de la implementación, storage del proxy
Cómo leer esta figura

Diagrama en dos partes. Parte superior: un usuario hace call al proxy (paso 1). El proxy tiene una caja de fallback que ejecuta delegatecall hacia la implementación V1 (paso 2, trae el código). Debajo del fallback está el storage del proxy: slot 0 owner, slot 1 totalSupply y el slot ERC-1967 implementation, calculado como keccak256 de eip1967.proxy.implementation menos 1. Una nota indica que el código de V1 lee y escribe esos slots del proxy (paso 3). Una flecha punteada desde el slot de implementación hacia la implementación V2 indica que un upgrade solo cambia el valor de ese slot. Parte inferior: panel de colisión. V1 declara address owner en el slot 0; V2 declara uint256 fee en el mismo slot 0; fee interpreta como número los bits de owner.

Visual original de TrautsLab. Arriba, la llamada del usuario entra al fallback del proxy, que trae el código de la implementación con delegatecall; ese código opera sobre los slots del proxy. Un upgrade solo reescribe el slot ERC-1967. Abajo, el fallo típico: una versión nueva reinterpreta un slot existente con otro tipo.

Falta una pieza: ¿dónde guarda el proxy la dirección de la implementación sin pisar las variables del contrato? ERC-1967 fija un slot estándar, 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, que es keccak256("eip1967.proxy.implementation") - 1. Define también slots para el admin y para un beacon, y recomienda emitir Upgraded(address indexed implementation) cada vez que cambia la implementación.

El estándar explica el - 1: el slot proviene del hash de un texto que no empieza con un índice de storage, así que el compilador nunca lo asigna a una variable, y restar uno hace que la preimagen del hash no sea conocida. Como el slot es estándar, un explorador o una herramienta de línea de comandos pueden leerlo sin conocer el código del proxy:

Bash
# Lee la implementación y el admin de un proxy ERC-1967 (Foundry cast)
cast implementation 0xPROXY --rpc-url "$RPC_URL"
cast admin 0xPROXY --rpc-url "$RPC_URL"
 
# Equivalente de bajo nivel: leer el slot directamente
cast storage 0xPROXY 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url "$RPC_URL"

cast implementation lee el slot de implementación de ERC-1967 o, con --beacon, el slot del beacon.

Invariante de storage: el layout es un contrato con el pasado

La regla que manda en este capítulo es una invariante: cada versión de la implementación debe interpretar los slots existentes del proxy igual que la versión anterior. Una invariante es una propiedad que debe seguir siendo verdadera después de cualquier operación; aquí, la operación es el upgrade.

¿Por qué se rompe con tanta facilidad? Solidity asigna slots a las variables de estado en orden de declaración, después de linealizar la herencia: la primera variable ocupa el primer slot, y así sucesivamente. ERC-1967 recoge esta regla para justificar por qué sus slots no colisionan. Si la versión 2 inserta una variable nueva al principio, todas las demás se corren un slot. El storage del proxy no se movió; lo que cambió es la lectura.

OpenZeppelin enumera los cambios prohibidos en una versión nueva: cambiar el tipo de una variable, cambiar el orden de declaración, introducir una variable antes de las existentes y eliminar una variable. Las variables nuevas van siempre al final. La herencia agrega una trampa: si un contrato base suma una variable, empuja las del contrato hijo.

Para eso existen dos convenciones. La primera son los storage gaps: un arreglo uint256[N] __gap en el contrato base que reserva slots para el futuro. La segunda es el namespaced storage de ERC-7201: las variables se agrupan en structs anotados con @custom:storage-location erc7201:<id>, ubicados en un slot derivado del identificador. La variante upgradeable de OpenZeppelin Contracts usa ERC-7201 desde la versión 5.0, y las herramientas de validación requieren Solidity 0.8.20 o superior para verificarlo.

Un efecto visible de ERC-7201: en un contrato que hereda OwnableUpgradeable versión 5, la primera variable propia queda en el slot 0, porque el owner vive en un slot con nombre y no en la secuencia lineal. El compilador lo confirma con su salida storageLayout; verificarlo en cada versión es más barato que suponerlo.

Transparent o UUPS: dónde vive la función de upgrade

Un proxy necesita, además del fallback, una función para cambiar la implementación. La pregunta es dónde ponerla, porque cualquier función pública del proxy puede chocar con una de la implementación. El selector de función son solo cuatro bytes, así que dos funciones con nombres distintos pueden compartir selector; ERC-1967 cita este riesgo como motivo para estandarizar un slot en lugar de un método público.

El proxy transparente resuelve el choque por identidad del llamador. Si quien llama es el admin, el proxy atiende upgradeToAndCall y no reenvía nada más; si es cualquier otra cuenta, todo se reenvía a la implementación. En OpenZeppelin 5.x, TransparentUpgradeableProxy despliega su propio ProxyAdmin con un initialOwner, y la documentación desaconseja extender el proxy con funciones nuevas, porque el compilador no detectaría choques de selector.

El patrón UUPS (Universal Upgradeable Proxy Standard) mueve la función de upgrade a la implementación. Lo documentó ERC-1822, que propuso guardar la dirección de la lógica en un slot fijo y exigir a la nueva implementación una función proxiableUUID() como chequeo de compatibilidad. ERC-1822 figura hoy como Stagnant en el repositorio de EIPs; su idea sobrevive en implementaciones como la de OpenZeppelin.

En UUPS, el proxy es un ERC1967Proxy que por sí mismo no es actualizable. La implementación hereda UUPSUpgradeable y debe sobrescribir _authorizeUpgrade con su control de acceso. proxiableUUID() devuelve el slot de implementación y revierte si se llama a través de un proxy, para que nadie actualice hacia algo que no es una implementación UUPS y bloquee el proxy para siempre.

Comparación

Transparent proxy frente a UUPS

TransparentUpgradeableProxy

La lógica de upgrade vive en el proxy y solo la usa el ProxyAdmin.

Ideal para
Equipos que prefieren no depender de que cada implementación conserve la función de upgrade.
Ojo con
Despliegue más caro; el admin no puede usar el proxy como usuario normal.
Recomendado

UUPS (ERC1967Proxy + UUPSUpgradeable)

La lógica de upgrade vive en la implementación y se autoriza en _authorizeUpgrade.

Ideal para
Proxies más livianos, con la opción de eliminar el upgrade en una versión final.
Ojo con
Una versión sin _authorizeUpgrade restringido, o sin UUPSUpgradeable, deja el proxy abierto o congelado.
Ambos usan el slot ERC-1967; cambia quién contiene la lógica de upgrade y qué error es posible.

OpenZeppelin advierte además que mezclar ambos, una implementación UUPS detrás de un proxy transparente, puede permitir que cuentas que no son admin ejecuten upgrades, porque los dos patrones comparten el slot de implementación.

Inicializadores: el constructor que puede llamar cualquiera

Un constructor se ejecuta una sola vez, al desplegar, y escribe en el storage del contrato que se está creando. En un sistema con proxy eso es un problema: el constructor de la implementación escribe en el storage de la implementación, no en el del proxy. Por eso los contratos actualizables reemplazan el constructor por una función común, normalmente initialize, que el proxy ejecuta vía delegatecall.

Una función común se puede llamar muchas veces y por cualquiera. Si initialize asigna el owner y no tiene protección, el primero que la llama se queda con el contrato. El modificador initializer de Initializable limita la ejecución a una vez; reinitializer(n) permite migraciones numeradas. Los valores iniciales en la declaración, como uint256 x = 42;, equivalen a código de constructor y no llegan al proxy; las constantes sí funcionan porque no ocupan storage.

El segundo hueco es la propia implementación. Nadie usa su storage, pero cualquiera puede llamarla directamente. OpenZeppelin indica no dejarla sin inicializar, porque un atacante puede tomar control de ella y eso puede afectar al proxy, y recomienda llamar _disableInitializers() en su constructor. El ejemplo siguiente compila con Solidity 0.8.26 y OpenZeppelin Contracts Upgradeable 5.1; es ilustrativo, no un contrato auditado:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
 
contract BoxV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 public value;
 
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers(); // la implementación queda bloqueada
    }
 
    function initialize(address initialOwner) external initializer {
        __Ownable_init(initialOwner);
        __UUPSUpgradeable_init();
    }
 
    function store(uint256 newValue) external onlyOwner {
        value = newValue;
    }
 
    // Sin este control, cualquiera podría reemplazar la lógica.
    function _authorizeUpgrade(address) internal override onlyOwner {}
}
 
contract BoxV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 public value;     // mismo slot que en V1
    uint256 public updatedAt; // variable nueva, siempre al final
 
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }
 
    function store(uint256 newValue) external onlyOwner {
        value = newValue;
        updatedAt = block.timestamp;
    }
 
    function _authorizeUpgrade(address) internal override onlyOwner {}
}

La documentación de OpenZeppelin prohíbe además selfdestruct y delegatecall en implementaciones, porque destruir la implementación dejaba a todos los proxies delegando a una dirección sin código. EIP-6780 cambió ese escenario: fuera de la transacción de creación, SELFDESTRUCT ya no borra código ni storage y solo transfiere el balance. La regla sigue siendo sana: un delegatecall arbitrario desde la implementación ejecuta código ajeno sobre el storage del proxy.

Riesgo operacional: quién puede reemplazar la lógica

Un proxy convierte una pregunta técnica en una pregunta de poder. Si una sola clave privada puede llamar upgradeToAndCall, esa clave puede reemplazar toda la lógica en una transacción, incluida la que mueve fondos. Para un usuario, el contrato que auditó no es el contrato que lo protege; lo protege la gobernanza del upgrade.

La revisión operativa tiene cuatro preguntas. ¿Qué dirección está en el slot de implementación y su código está verificado? ¿Quién es el admin o el owner que autoriza upgrades, y es una clave sola, un multisig o un timelock? ¿Hay un retraso entre anunciar y ejecutar un upgrade? ¿El monitoreo alerta ante cada evento Upgraded y AdminChanged? ERC-1967 recomienda emitir esos eventos justamente porque seguir cambios de slots es difícil sin ellos.

Antes de aprobar un upgrade

Controles mínimos

0 de 6 revisados

Cada punto deja evidencia revisable; ninguno reemplaza a los demás.

El capítulo de control de acceso desarrolla multisig y timelock. Lo importante aquí es que "actualizable" y "seguro" no son sinónimos: un proxy sin gobierno es un contrato que su admin puede reescribir a voluntad.

Práctica sugerida: romper y reparar un layout

Un ejercicio local, en Anvil o en tests de Foundry, deja la regla grabada. Despliega BoxV1 detrás de un ERC1967Proxy, guarda un valor y luego actualiza a una versión que declare una variable nueva antes de value. Lee value después del upgrade: el número ya no es el que guardaste, aunque nadie escribió en el proxy.

Después repite con BoxV2, que agrega updatedAt al final. El valor sobrevive. Compara ambas salidas storageLayout del compilador y lee el slot de implementación con cast implementation antes y después. La evidencia de éxito es concreta: mismo valor, mismo owner, implementación nueva y un evento Upgraded en los logs. Todo el ejercicio debe correr en una red local; nada de esto necesita mainnet.

Conexiones dentro de la serie

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    S. Palladino, F. Giordano, and H. Croubois, "ERC-1967: Proxy Storage Slots," Ethereum Improvement Proposals, no. 1967, Apr. 2019.

    Slots de implementación, admin y beacon; eventos Upgraded, AdminChanged y BeaconUpgraded; justificación del -1.

  2. [2]

    G. Barros and P. Gallagher, "ERC-1822: Universal Upgradeable Proxy Standard (UUPS)," Ethereum Improvement Proposals, no. 1822, Mar. 2019.

    Proxy con slot fijo para la lógica, contrato Proxiable con proxiableUUID y recomendaciones de separación de variables.

  3. [3]

    Solidity Team, "Delegatecall and Libraries," Solidity Documentation, Introduction to Smart Contracts.

    Documentation siteID: SOLIDITY-DELEGATECALLAbrir fuente (abre en otra pestaña)

    Define que delegatecall ejecuta código ajeno con storage, dirección y balance del llamador, sin cambiar msg.sender ni msg.value.

  4. [4]

    OpenZeppelin, "Proxies," OpenZeppelin Contracts 5.x API Reference.

    Documentation siteID: OZ-PROXY-API-5XAbrir fuente (abre en otra pestaña)

    TransparentUpgradeableProxy, ProxyAdmin, ERC1967Proxy, UUPSUpgradeable, Initializable y comparación Transparent vs UUPS.

  5. [5]

    OpenZeppelin, "Writing Upgradeable Contracts," OpenZeppelin Upgrades Plugins Documentation.

    Documentation siteID: OZ-WRITING-UPGRADEABLEAbrir fuente (abre en otra pestaña)

    Inicializadores, _disableInitializers, operaciones inseguras, reglas de layout, storage gaps y namespaced storage ERC-7201.

  6. [6]

    G. Ballet, V. Buterin, and D. Feist, "EIP-6780: SELFDESTRUCT only in same transaction," Ethereum Improvement Proposals, no. 6780, Mar. 2023.

    SELFDESTRUCT transfiere el balance pero no borra cuenta, código ni storage salvo en la misma transacción de creación.

  7. [7]

    Foundry Contributors, "cast implementation," Foundry Book, CLI reference.

    Documentation siteID: FOUNDRY-CAST-IMPLEMENTATIONAbrir fuente (abre en otra pestaña)

    Comando que obtiene la implementación de un proxy ERC-1967 desde el slot de implementación o de beacon.

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.