El patrón UUPS es un patrón de proxy donde la función de actualización reside en el contrato de implementación, pero cambia la dirección de implementación almacenada en el contrato proxy a través de un delegatecall desde el proxy. El mecanismo de alto nivel se muestra en la siguiente animación:

De manera similar al Transparent Upgradeable Proxy, el patrón UUPS resuelve el problema de las colisiones de selectores de funciones al eliminar por completo las funciones públicas en el proxy.
Este artículo asume que el lector ha leído nuestro artículo sobre el patrón Transparent Upgradeable Proxy.
El estándar de ranuras de almacenamiento de proxies ERC-1967
Como se indicó en nuestro artículo sobre Transparent Upgradeable Proxy, un proxy funcional de Ethereum requiere al menos las siguientes dos características:
- Una ranura de almacenamiento que contenga la dirección del contrato de implementación.
- Un mecanismo para que un administrador cambie la dirección de implementación.
El estándar ERC-1967 dicta dónde debe mantenerse la ranura de almacenamiento que contiene la dirección de la implementación, pero no dicta cómo cambiar la dirección de la implementación, es decir, deja la elección del mecanismo de actualización en manos del desarrollador.
UUPS es un patrón de proxy en el que el mecanismo para cambiar la dirección del contrato de implementación reside en el propio contrato de implementación en lugar de en el contrato proxy.
Esta diferencia se ilustra en el siguiente código simplificado:

Durante una actualización, la función _upgradeLogic() se invoca mediante delegatecall en el UUPSProxy. A diferencia de un Transparent Upgradeable Proxy, no hay necesidad de un AdminProxy — un EOA normal puede ser el administrador si se desea.
El Transparent Upgradeable Proxy utilizaba un AdminProxy para mantener constante la dirección del administrador. Dado que un Transparent Upgradeable Proxy tiene que comparar msg.sender con el administrador en cada transacción, es deseable comparar msg.sender con una variable inmutable. Sin embargo, un proxy UUPS solo necesita verificar si msg.sender es el administrador cuando este llama explícitamente a _upgradeLogic() en el proxy (el cual ejecuta un delegatecall a _upgradeLogic() en la implementación).
Una de las ventajas de este patrón es que la lógica de implementación en sí misma puede ser actualizada, es decir, el mecanismo de capacidad de actualización puede modificarse de una implementación a otra. Por ejemplo, se hace posible la transición de una lógica de actualización simple a una más compleja con mecanismos de votación o de bloqueo de tiempo.
Una contrapartida importante de este estándar es que si se realiza una actualización a un nuevo contrato de implementación que carece de un mecanismo de actualización válido, la cadena de actualizaciones termina, ya que no es posible pasar a la siguiente implementación. En otras palabras, dado que el propio mecanismo de actualización puede ser actualizable, existe el riesgo de romper el mecanismo de actualización.
Para lidiar con esta contrapartida, se han presentado algunas propuestas que primero verifican si el nuevo contrato de implementación tiene un mecanismo de actualización válido antes de migrar a él. UUPS es una de esas propuestas.
En este artículo, explicaremos cómo funciona UUPS en general, examinaremos la implementación de OpenZeppelin en detalle y discutiremos algunas vulnerabilidades que deben tenerse en cuenta al usar este patrón.
UUPS frente a Transparent Proxy
Actualmente, OpenZeppelin proporciona implementaciones para los estándares de proxy Transparent y UUPS, pero recomienda usar este último. La razón es que, además de la flexibilidad para modificar el mecanismo de actualización, la implementación de UUPS es más ligera y, por lo tanto, consume menos gas durante su despliegue y uso.
Esto se debe a que no hay necesidad de desplegar un contrato de administración ni de verificar si la transacción proviene del propietario del contrato, como se requiere en los Transparent Proxies. Sin embargo, cada nuevo contrato de implementación en este patrón sí necesita una función de actualización, lo que eleva ligeramente los costos de despliegue del nuevo contrato de implementación.
Si los contratos de implementación están alcanzando el límite de tamaño de 24kb usando UUPS, entonces el patrón Transparent Upgradeable puede ser más adecuado ya que no necesita incluir la lógica de actualización.
Cómo funciona UUPS
UUPS se definió inicialmente en el ERC-1822.
Como vimos en la sección anterior, es necesario evitar que el contrato proxy acepte un nuevo contrato de implementación que no implemente el estándar UUPS. En otras palabras, cualquier intento de migrar a un contrato de implementación que no sea compatible con UUPS debería revertirse.
La función proxiableUUID()
Es por eso que el estándar exige que todo contrato de implementación incorpore una función con la firma proxiableUUID(). El propósito de esta función es servir como una verificación de compatibilidad para asegurar que el nuevo contrato de implementación cumple con el Universal Upgradeable Proxy Standard.
La función debe devolver la ranura de almacenamiento donde se guarda la dirección de implementación. Aunque el valor de retorno es arbitrario y los defensores del estándar podrían haber definido la función para devolver una cadena de texto como “Hey, I’m UUPS compliant”, devolver la ranura de almacenamiento es más eficiente en cuanto a gas.
La idea es invocar la función proxiableUUID() en la nueva implementación antes de realmente migrar a ella. Si el nuevo contrato de implementación ha implementado correctamente proxiableUUID(), se considera compatible con UUPS y la migración debería continuar. De lo contrario, la transacción debe ser revertida.
El proceso tanto de una migración exitosa como de un intento de migración fallido se ilustran en la siguiente figura.

La ranura de almacenamiento
La propuesta original sugiere que la dirección de la ranura de almacenamiento se defina mediante la fórmula keccak256("PROXIABLE"), sin embargo, dado que la implementación de OpenZeppelin utiliza el estándar ERC-1967, la dirección de la ranura en su implementación está definida por keccak256("eip1967.proxy.implementation") - 1. Veremos esto en el código en breve.
A continuación se puede ver una animación que muestra el proceso de migración a una nueva implementación:
Recorrido por UUPS Upgradeable de OpenZeppelin
El contrato que implementa el estándar UUPS en la biblioteca de OpenZeppelin se llama UUPSUpgradeable.sol. Este contrato debe ser heredado por los contratos de implementación, no por el contrato proxy. El proxy generalmente hereda de ERC1967Proxy, un contrato proxy mínimo que cumple con el estándar ERC-1967.
El propósito de UUPSUpgradeable.sol es doble:
- Proporciona la función
proxiableUUID(), que toda implementación debe incluir para ser compatible con UUPS, - También proporciona la función
updateToAndCall(), que se utiliza para migrar a una nueva implementación. Una función con este propósito, como hemos visto, debe estar presente en todo contrato de implementación.
La función proxiableUUID()
La función proxiableUUID(), que debe llamarse en los nuevos contratos de implementación antes de las migraciones, se define de la siguiente manera, y devuelve la ranura de almacenamiento del estándar ERC-1967.
function proxiableUUID() external view virtual notDelegated returns (bytes32) {
return ERC1967Utils.IMPLEMENTATION_SLOT; // conformal to the ERC-1967 standard
}
La función upgradeToAndCall
La función responsable de actualizar a la siguiente implementación puede tener cualquier nombre. Dado que se define dentro del contrato de implementación, no hay riesgo de colisiones de firmas de funciones, tal como también ocurre en el Transparent Proxy.
En UUPSUpgradeable.sol esta función se llama upgradeToAndCall y su definición se proporciona a continuación:
function upgradeToAndCall(address newImplementation, bytes memory data) public payable virtual onlyProxy {
// checks whether the upgrade can proceed
_authorizeUpgrade(newImplementation);
// upgrade to the new implementation
_upgradeToAndCallUUPS(newImplementation, data);
}
function _authorizeUpgrade(address newImplementation) internal virtual;
function _upgradeToAndCallUUPS(address newImplementation, bytes memory data) private {
// checks whether the new implementation implements ERC-1822
try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) {
if (slot != ERC1967Utils.IMPLEMENTATION_SLOT) {
revert UUPSUnsupportedProxiableUUID(slot);
}
ERC1967Utils.upgradeToAndCall(newImplementation, data);
} catch {
// The implementation is not UUPS
revert ERC1967Utils.ERC1967InvalidImplementation(newImplementation);
}
}
Dado que es una función pública que debería llamarse solo a través del proxy activo, cuenta con un modificador onlyProxy para asegurarlo.
La función interna _authorizeUpgrade
Es responsabilidad del desarrollador implementar la función _authorizeUpgrade en el código. Esta función determina quién puede realizar la actualización. Una implementación directa podría simplemente verificar la propiedad para realizar la actualización, como se muestra a continuación:
function _authorizeUpgrade(address newImplementation)
internal onlyOwner override {}
Como se mencionó anteriormente, cada nueva implementación puede tener su propia función _authorizeUpgrade con una lógica única. Por ejemplo, si el propietario desea cambiar a un esquema multifirma en una nueva implementación, el código necesario puede incluirse en esta función.
Debido a que UUPSUpgradeable es un contrato abstracto, el código no se compilará a menos que implementes explícitamente _authorizeUpgrade.
Aprendiendo sobre UUPS usando Remix
En esta sección, utilizaremos Remix para obtener una visualización más clara del funcionamiento fundamental de UUPS. Aunque Remix ofrece características avanzadas de despliegue mediante proxies, no usaremos dichas características aquí para mantener transparentes los procesos subyacentes.
Además, para hacer el código más conciso, omitiremos las funciones de inicialización y los modificadores. Esto hará que nuestro código sea inseguro, pero el objetivo es centrarnos en comprender el concepto central de cómo opera UUPS.
El contrato proxy
Nuestro contrato proxy utilizará la biblioteca ERC1967Proxy.sol, que implementa un esquema de proxy mínimo compatible con el estándar ERC-1967. La dirección del contrato de implementación inicial se pasa en el constructor. Sin embargo, el proxy en sí carece de un mecanismo para actualizarse a una nueva implementación; este mecanismo debe implementarse dentro del propio contrato de implementación.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract UUPSProxy is ERC1967Proxy {
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data) payable {}
}
Aquí tienes el código para copiar y pegar:
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract UUPSProxy is ERC1967Proxy {
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data)
payable {}
}
El contrato de implementación
Los contratos de implementación deben heredar del contrato UUPSUpgradeable, el cual sigue el patrón UUPS e incluye un mecanismo para pasar a la siguiente implementación. Es esencial sobreescribir la función _authorizeUpgrade, ya que el mecanismo de autorización no está predefinido y debe ser implementado.
En el código a continuación, implementamos esto de una manera altamente insegura, ya que cualquier persona está autorizada para realizar la actualización.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract UUPSProxy is ERC1967Proxy {
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data) payable {}
}
// UUPS Implementation
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract ImplementationOne is UUPSUpgradeable {
function myNumber() public pure returns (uint256) {
return 1; // A function to test the implementation
}
// In practice, this function should include an onlyOwner modifier
// or some other form of ownership protection mechanism
function _authorizeUpgrade(address _newImplementation) internal override {}
}
Para definir un propietario para el contrato, se debe crear una función de inicialización, ya que los contratos de implementación no deben usar constructores. Puedes aprender más sobre este tema en nuestro artículo sobre el contrato Initializable.sol. Nuevamente, aquí está el código:
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract ImplementationOne is UUPSUpgradeable {
function myNumber() public pure returns (uint256) {
return 1; // A function to test the implementation
}
// In practice, this function should include an onlyOwner modifier
// or some other form of ownership protection mechanism
function _authorizeUpgrade(address _newImplementation) internal override {}
}
Para probar los contratos anteriores en Remix, debes seguir los siguientes pasos:
- Despliega el contrato de implementación llamado
ImplementationOne. - Despliega el contrato proxy llamado
MyProxy. El constructor requiere dos parámetros: la dirección del contrato de implementación (la dirección deImplementationOne) y un argumento de tipobytespara inicializar el contrato de implementación. Este argumento puede ser0x, ya que no se utilizará. - Para probar el contrato, abre una instancia del contrato proxy utilizando el ABI del contrato de implementación. Para hacer esto, en la pestaña de despliegue, selecciona el contrato de implementación,
ImplementationOne, e ingresa la dirección del contrato proxy en el campo At Address, como se muestra en la figura a continuación.

Ahora podrás ejecutar la función myNumber() en el contrato de implementación a través del proxy.
Pasando a la siguiente implementación
Para pasar a la siguiente implementación, primero se debe crear un nuevo contrato que siga el patrón UUPS, similar al ejemplo a continuación.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract UUPSProxy is ERC1967Proxy {
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data) payable {}
}
// UUPS Implementation
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract ImplementationOne is UUPSUpgradeable {
function myNumber() public pure returns (uint256) {
return 1; // A function to test the implementation
}
// In practice, this function should include an onlyOwner modifier
// or some other form of ownership protection mechanism
function _authorizeUpgrade(address _newImplementation) internal override {}
}
// NEW UUPS Implementation
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract ImplementationTwo is UUPSUpgradeable {
function myNumber() public pure returns (uint256) {
return 2; // A function to test the implementation
}
// In practice, this function should include an onlyOwner modifier
// or some other form of ownership protection mechanism
function _authorizeUpgrade(address _newImplementation) internal override {}
}
// ---- updated implementation ----
contract ImplementationTwo is UUPSUpgradeable {
function myNumber() public pure returns (uint256) {
return 2; // A function to test the implementation
}
// In practice, this function should include an onlyOwner modifier
// or some other form of ownership protection mechanism
function _authorizeUpgrade(address _newImplementation) internal override {}
}
Después de crear el contrato, los siguientes pasos son los siguientes:
- Despliega
ImplementationTwo. - Invoca la función
upgradeToAndCallen el contrato de implementación anterior a través del proxy, pasando la dirección deImplementationTwocomo primer parámetro (el segundo parámetro puede ser0x). La funciónproxiableUUID()está definida en el contrato padre, y su valor de retorno correcto será verificado antes de la migración.
El intento de migrar a una implementación que no es compatible con UUPS fallará porque el mecanismo de control de seguridad a través de la función proxiableUUID() lo impedirá.
Vulnerabilidades en UUPS
1. Vulnerabilidad con contratos no inicializados
Es común inicializar los contratos de implementación. Por ejemplo, en el caso de un contrato ERC20 actualizable, es típico establecer el nombre y el símbolo del token durante el despliegue. Este procedimiento generalmente se realiza con constructores. Sin embargo, usar constructores en el contrato de implementación no es útil, ya que alteraría el almacenamiento de la implementación, mientras que el ‘verdadero’ almacenamiento reside en el contrato proxy.
Para inicializar los contratos de implementación, debemos depender de funciones regulares que están configuradas para ejecutarse solo una vez. Esto se puede hacer utilizando los modificadores proporcionados por OpenZeppelin en la biblioteca Initializable.sol.
Un ejemplo de una función de inicialización es el siguiente código.
function initialize(address initialOwner) initializer public {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
Tomando la propiedad de la implementación
Una vulnerabilidad importante radica en el hecho de que esta es una función pública. Se supone que debe llamarse a través del proxy, pero también puede llamarse directamente en el contrato de implementación. Dado que establece el propietario del contrato, quien llame a esta función primero de manera directa en el contrato de implementación se convertirá en el “propietario” de ese contrato.
Para aclarar, en este punto, habrá dos propietarios del contrato:
- El propietario establecido a través del proxy.
- El “propietario” establecido al llamar al contrato de implementación directamente.

Cualquier función marcada como onlyOwner podrá ser llamada por cualquiera de estos dos propietarios. Este comportamiento no intencionado puede representar un riesgo para el contrato, como veremos en breve.
La solución
La solución a esta vulnerabilidad es siempre “inicializar” el contrato de implementación configurando las variables de estado deseadas directamente dentro del contrato de implementación. Por ejemplo, debes establecer el propietario del contrato de implementación o evitar que cualquiera llame a la función de inicialización directamente en el contrato de implementación.
OpenZeppelin proporciona la función _disableInitializers() que debe ejecutarse en el constructor para lograr esto, como se muestra en el siguiente código:
constructor() {
_disableInitializers();
}
2. Vulnerabilidad a través de delegatecall
En el contrato de implementación, debes evitar el uso de delegatecall hacia contratos arbitrarios. El mayor riesgo solía residir en hacer involuntariamente un delegatecall hacia un código de operación selfdestruct. Desde la bifurcación Cancun, selfdestruct ya no elimina el código del contrato. Sin embargo, la recomendación de evitar el uso de delegatecall en los contratos de implementación sigue vigente, probablemente debido a su uso en cadenas donde selfdestruct aún está activo.
Una vulnerabilidad severa en la implementación de UUPS de OpenZeppelin en las versiones de Contracts v4.1.0 a v4.3.1 fue causada por la combinación de las dos vulnerabilidades mencionadas:
El código para pasar a la siguiente implementación, además de cambiar la dirección de implementación, incluía un delegatecall para inicializar el nuevo contrato. Esta función, upgradeToAndCall, solo podía ser ejecutada por el propietario y estaba destinada a ser llamada exclusivamente a través del proxy. Sin embargo, como se mencionó anteriormente, si el contrato no estaba correctamente “inicializado”, cualquiera podía asumir el papel de propietario y usar la función upgradeToAndCall para realizar un delegatecall hacia un contrato que contuviera un código de operación selfdestruct.
Debido a las vulnerabilidades mencionadas, OpenZeppelin considera que no es seguro usar delegatecall dentro de los contratos de implementación.
Una lista de verificación para usar UUPS
Aquí hay algunas pautas que se deben seguir al utilizar el estándar UUPS en la biblioteca de OpenZeppelin:
- Ten mucho cuidado si sobreescribes
upgradeToAndCallpara no interrumpir la funcionalidad de actualización. - Asegúrate de que
_authorizeUpgradeincluya un modificadoronlyOwneru otro mecanismo que restrinja el acceso solo a cuentas autorizadas. - En una actualización, ten cuidado al cambiar el esquema de autorización en la nueva versión del contrato de implementación. Por ejemplo, al cambiar a un tipo de autorización donde el administrador ha renunciado previamente a sus privilegios y esto ha pasado desapercibido.
- Usa la función
_disableInitializers()en el constructor del contrato de implementación para prevenir la inicialización. - No se utiliza
delegatecallniselfdestruct.
Nos gustaría agradecer a ernestognw.eth de OpenZeppelin por revisar un borrador anterior de este trabajo.
Publicado originalmente el 26 de agosto de 2024