Saltar al contenido

Cómo escribir contratos inteligentes actualizables

Explora la importancia de los contratos inteligentes actualizables en aplicaciones descentralizadas. Aprende cómo permiten actualizaciones, correcciones de errores y mayor seguridad mediante el patrón proxy.

8 min de lectura
Cómo escribir contratos inteligentes actualizables

Los contratos inteligentes han transformado nuestra forma de concebir las aplicaciones descentralizadas al introducir programas autoejecutables que eliminan la necesidad de intermediarios. Se ejecutan de forma transparente en redes blockchain descentralizadas y ofrecen ventajas como la ausencia de confianza requerida y una mayor seguridad. Sin embargo, su inmutabilidad, antes considerada una fortaleza, plantea un desafío importante: una vez desplegado un contrato inteligente, no puede modificarse. Esta rigidez dificulta la implementación de actualizaciones necesarias o la corrección de errores.

En este artículo exploraremos el concepto de contratos inteligentes actualizables, explicaremos por qué son esenciales y detallaremos su funcionamiento, con especial atención a su implementación en Linea zkEVM.

¿Qué son los contratos inteligentes actualizables?

Los contratos inteligentes actualizables resuelven las limitaciones de los contratos tradicionales al permitir modificaciones y mejoras tras su despliegue. Mientras que los contratos tradicionales son conocidos por su inmutabilidad, los contratos actualizables combinan la seguridad de la inmutabilidad con la flexibilidad de adaptarse. Esto permite a los desarrolladores implementar actualizaciones sin interrumpir a los usuarios ni obligarlos a migrar a un nuevo contrato.

En los contratos tradicionales, los errores o fallos de seguridad son permanentes una vez desplegados. Los contratos actualizables abordan esto habilitando actualizaciones tras el despliegue, resolviendo problemas clave como:

1. Corrección de errores y vulnerabilidades de seguridad: Los desarrolladores pueden parchear vulnerabilidades después del despliegue, protegiendo los activos de los usuarios.

2. Adaptabilidad: Los contratos actualizables permiten una integración fluida con nuevos protocolos, manteniendo las aplicaciones al día con los avances tecnológicos.

3. Mejoras de seguridad: Ante la evolución de las amenazas cibernéticas, estos contratos facilitan la implementación de nuevas medidas de seguridad sin necesidad de redesplegar el contrato completo.

4. Flexibilidad de gobernanza: Las Organizaciones Autónomas Descentralizadas (DAOs) pueden utilizar la votación comunitaria para gestionar el proceso de actualización, garantizando la transparencia y la participación de las partes interesadas.

5. Actualizaciones simplificadas: A diferencia de los contratos tradicionales que requieren un redespliegue complejo y migración de datos, los contratos actualizables simplifican las actualizaciones para una experiencia más eficiente y amigable para el usuario.

¿Cómo funcionan los contratos inteligentes actualizables?

Para crear un contrato inteligente actualizable, el método más común es el patrón proxy. Este enfoque separa la lógica del contrato de sus datos, lo que permite a los desarrolladores actualizar la lógica manteniendo el mismo almacenamiento de datos.

Así es como funciona:

  1. Contrato Proxy: Este contrato almacena el estado (variables de estado) y actúa como interfaz para los usuarios.

  2. Contrato de Implementación: Este contrato contiene el código ejecutable (lógica de negocio).

  3. Delegación: El contrato proxy delega las llamadas a funciones al contrato de implementación mediante el delegatecall de Ethereum. Esto permite al proxy ejecutar el código de la implementación manteniendo el mismo almacenamiento.

Cuando se requiere una actualización, el contrato proxy se redirige a un nuevo contrato de implementación, lo que permite actualizar la lógica sin modificar el almacenamiento del contrato ni interrumpir a los usuarios. A continuación se muestra un ejemplo utilizando un contrato proxy de DAO para ilustrar este proceso:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract DAOProxy {
    // Storage slot for the address of the implementation contract
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    // Storage slot for the admin address
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");

    // Modifier to restrict access to admin
    modifier onlyAdmin() {
        require(msg.sender == _admin(), "Caller is not admin");
        _;
    }

    // Constructor to set the initial implementation and admin
    constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }

    // Fallback function to delegate calls to the implementation
    fallback() external payable {
        _delegate(implementation());
    }

    // Receive function to handle plain Ether transfers
    receive() external payable {
        _delegate(implementation());
    }

    // Internal function to delegate the call to the implementation
    function _delegate(address _implementation) internal {
        assembly {
            // Copy msg.data
            calldatacopy(0, 0, calldatasize())
            // Call the implementation
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            // Copy the returned data
            returndatacopy(0, 0, returndatasize())
            // Handle result
            switch result
            case 0 {
                // Revert if the call failed
                revert(0, returndatasize())
            }
            default {
                // Return data if the call succeeded
                return(0, returndatasize())
            }
        }
    }

    // Getter for the implementation address
    function implementation() internal view returns (address impl) {
        bytes32 slot = implementationSlot;
        assembly {
            impl := sload(slot)
        }
    }

    // Getter for the admin address
    function _admin() internal view returns (address adm) {
        bytes32 slot = adminSlot;
        assembly {
            adm := sload(slot)
        }
    }

    // Function to upgrade the implementation contract
    function upgradeTo(address newImplementation) external onlyAdmin {
        _setImplementation(newImplementation);
    }

    // Internal function to set the implementation address
    function _setImplementation(address newImplementation) internal {
        bytes32 slot = implementationSlot;
        assembly {
            sstore(slot, newImplementation)
        }
    }

    // Function to change admin
    function changeAdmin(address newAdmin) external onlyAdmin {
        _setAdmin(newAdmin);
    }

    // Internal function to set the admin address
    function _setAdmin(address newAdmin) internal {
        bytes32 slot = adminSlot;
        assembly {
            sstore(slot, newAdmin)
        }
    }
}
Copiar
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");
Copiar

Dos aspectos importantes a tener en cuenta son el slot de almacenamiento para la dirección del contrato de implementación y el slot de almacenamiento para la dirección del administrador. Estos son identificadores deliberados y únicos utilizados para calcular un slot de almacenamiento para la dirección de implementación dentro del contrato proxy.

Al utilizar el patrón proxy con delegatecall (analizaremos esta función más en detalle próximamente), el contrato de implementación opera en el contexto del almacenamiento del proxy. Esto significa:

  • Espacio de almacenamiento compartido: Ambos contratos comparten el mismo esquema de almacenamiento.

  • Riesgo de sobreescritura: Sin una gestión cuidadosa, las variables del contrato proxy podrían ser sobreescritas por el contrato de implementación.

Al utilizar un identificador único con hash:

  • Aislamiento: Se garantiza que los slots de almacenamiento utilizados por el proxy no entren en conflicto con ninguna variable del contrato de implementación.

  • Consistencia: El mismo slot de almacenamiento se utiliza de forma consistente en todo el contrato donde se necesite la dirección de implementación.

constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }
Copiar

Otro aspecto importante es el constructor del contrato proxy. Incluye un parámetro _implementation, lo que significa que el contrato de implementación debe desplegarse primero y su dirección se pasa como argumento al desplegar el contrato proxy.

 fallback() external payable {
        _delegate(implementation());
    }

    receive() external payable {
        _delegate(implementation());
    }
Copiar

Ahora tenemos las funciones fallback y receive, que son funciones de Solidity y desempeñan roles fundamentales en el reenvío de llamadas y transferencias de Ether al contrato de implementación. Gestionan situaciones en las que un contrato recibe llamadas o fondos que no coinciden con ninguna de sus funciones explícitamente definidas. A continuación se explica en detalle qué hacen estas funciones:

1. La función fallback

  • Mecanismo fallback: La función fallback en Solidity es una función especial que se ejecuta bajo dos condiciones principales:- Cuando se llama a una función que no existe en el contrato.

  • Cuando se envían datos al contrato sin que coincidan con ninguna firma de función.

  • Delegación: En el contexto del contrato DAOProxy, la función fallback delega todas esas llamadas al contrato de implementación actual.

Cómo funciona en DAOProxy:

  • Gestión de llamadas a funciones:- Cuando un usuario llama a una función no definida en el contrato proxy, se activa la función fallback.

  • Esto es habitual porque el contrato proxy contiene principalmente funciones administrativas como upgradeTo y changeAdmin, en lugar de las funciones de lógica de negocio del contrato de implementación.

  • Delegación a la implementación:- La función fallback llama a _delegate(implementation()), reenviando la llamada al contrato de implementación.

  • _delegate es una función interna que utiliza delegatecall para ejecutar el código de la implementación operando en el contexto de almacenamiento del proxy.

  • Contexto de ejecución:- Todos los cambios de estado ocurren en el almacenamiento del proxy.

  • msg.sender y msg.value permanecen sin cambios, preservando el contexto del llamador original.

2. La función receive

  • Recepción de Ether: La función receive, introducida en Solidity 0.6.0, se ejecuta cuando un contrato recibe Ether sin ningún dato adicional (es decir, cuando msg.data está vacío).

  • Delegación de transferencias de Ether: En el contrato DAOProxy, las transferencias simples de Ether se delegan al contrato de implementación.

Cómo funciona en DAOProxy:

  • Gestión de transferencias de Ether:- Cuando alguien envía Ether a la dirección del contrato proxy sin datos, se activa la función receive.

  • Esto ocurre a través de una transacción que especifica únicamente la dirección to y el value (cantidad de Ether), sin llamar a ninguna función específica.

  • Delegación a la implementación:- Al igual que la función fallback, receive llama a _delegate(implementation()), reenviando la transferencia de Ether al contrato de implementación.

  • El contrato de implementación define entonces cómo gestionar el Ether recibido (por ejemplo, actualizando saldos o emitiendo eventos).

 function _delegate(address _implementation) internal {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
Copiar

Ahora tenemos la función _delegate, que permite al proxy reenviar llamadas a funciones al contrato de implementación preservando el contexto de la llamada original. Esto permite que la lógica del contrato se actualice con el tiempo sin cambiar la dirección del contrato ni alterar su estado.

Las demás funciones son getters y setters que nos ayudan a gestionar las variables de estado de nuestro contrato proxy. Para lograrlo, utilizamos la palabra clave assembly, que permite a los desarrolladores incluir código ensamblador de bajo nivel directamente en sus contratos. Esta característica proporciona acceso directo a las instrucciones de la Máquina Virtual de Ethereum (EVM), lo que permite un control más preciso sobre el comportamiento del contrato, la optimización del consumo de gas y la ejecución de operaciones que no están disponibles directamente o son menos eficientes en el código de alto nivel de Solidity.

Ahora, la gran pregunta es: ¿cómo interactuamos con nuestro contrato inteligente de implementación usando este contrato proxy?

Para ello, necesitamos el ABI del contrato inteligente de implementación. Dado que el proxy delega las llamadas al contrato de implementación, necesitas su ABI para saber qué funciones puedes invocar. Además, utilizaremos la dirección del contrato proxy al interactuar con la blockchain, no la del contrato de implementación. El proxy almacena el estado y delega las llamadas a la implementación, por lo que todas las interacciones deben realizarse a través del proxy.

Imaginemos que tenemos un proyecto en JavaScript y estamos usando Ethers.js para interactuar con la blockchain. Así es como se vería:

const proxyAddress = "0xProxyContractAddress";
const implementationABI = [ /* ABI array of the implementation contract */ ];

const proxyContract = new ethers.Contract(proxyAddress, implementationABI, provider);

// For a function 'setValue(uint256 newValue)'
await proxyContract.connect(signer).setValue(42);

// For a function 'getValue()'
const value = await proxyContract.getValue();
console.log("Value:", value);
Copiar

De esta forma, estamos llamando a la función imaginaria setValue en nuestro contrato de implementación usando nuestro contrato proxy.

Ahora que estamos hablando de contratos actualizables, surge otra pregunta importante: ¿cómo actualizo mis contratos?

En primer lugar, solo el administrador (la persona que desplegó el contrato proxy) puede actualizar el contrato de implementación. Para ello, el administrador debe primero desplegar una nueva versión del contrato de implementación en la blockchain. Después, debe llamar a la función upgradeTo en el contrato proxy, pasando la nueva dirección del contrato como parámetro. Se vería así:

await proxyContract.connect(adminSigner).upgradeTo(newImplementationAddress);

Adoptando el futuro de los contratos inteligentes

Los contratos inteligentes actualizables son fundamentales en el desarrollo blockchain porque permiten realizar cambios y mejoras tras el despliegue. Al aprovechar el patrón proxy, los desarrolladores pueden introducir nuevas funcionalidades de forma fluida, corregir errores y responder a requisitos cambiantes sin interrumpir la experiencia del usuario ni migrar activos.

Sin embargo, un gran poder conlleva una gran responsabilidad. Es fundamental abordar los contratos actualizables con un conocimiento profundo de su mecánica, sus implicaciones de seguridad y las mejores prácticas. Garantizar la consistencia del esquema de almacenamiento, implementar controles de acceso robustos y probar exhaustivamente las actualizaciones son pasos esenciales para protegerse frente a posibles riesgos.

A medida que te adentres en la construcción de contratos actualizables, considera utilizar herramientas consolidadas como el Plugin de Actualizaciones de OpenZeppelin para simplificar el proceso y reforzar la seguridad. ¡Feliz programación!

Traducido por IA. Puede contener errores. Por favor, verifique siempre la información.

Califica la traducción

Este artículo fue escrito por: