Перейти к содержимому

Как писать обновляемые смарт-контракты

Узнайте о важности обновляемых смарт-контрактов в децентрализованных приложениях. Как они позволяют вносить обновления, исправлять ошибки и повышать безопасность через паттерн прокси.

8 мин чтения
Как писать обновляемые смарт-контракты

Смарт-контракты изменили наше представление о децентрализованных приложениях, введя самоисполняющиеся программы, устраняющие необходимость в посредниках. Они прозрачно выполняются в децентрализованных блокчейн-сетях, предлагая такие преимущества, как отсутствие необходимости доверия и повышенная безопасность. Однако их неизменяемость, которая когда-то считалась достоинством, создаёт серьёзную проблему: после развёртывания смарт-контракт нельзя изменить. Эта жёсткость затрудняет внедрение необходимых обновлений или исправление ошибок.

В этой статье мы рассмотрим концепцию обновляемых смарт-контрактов, объясним, почему они необходимы, и разберём принцип их работы, уделив особое внимание их реализации на Linea zkEVM.

Что такое обновляемые смарт-контракты?

Обновляемые смарт-контракты решают ограничения традиционных смарт-контрактов, позволяя вносить изменения и улучшения после развёртывания. В то время как традиционные смарт-контракты известны своей неизменяемостью, обновляемые контракты сочетают безопасность неизменяемости с гибкостью адаптации. Это позволяет разработчикам внедрять обновления, не нарушая работу пользователей и не требуя от них миграции на новый контракт.

В традиционных контрактах ошибки или уязвимости в системе безопасности становятся постоянными после развёртывания. Обновляемые контракты решают эту проблему, позволяя вносить изменения после развёртывания и устраняя ключевые проблемы, такие как:

1. Исправление ошибок и уязвимостей безопасности: разработчики могут устранять уязвимости после развёртывания, защищая активы пользователей.

2. Адаптивность: обновляемые контракты обеспечивают бесшовную интеграцию с новыми протоколами, поддерживая актуальность приложений в соответствии с технологическим прогрессом.

3. Улучшение безопасности: с учётом развивающихся киберугроз эти контракты упрощают внедрение новых мер безопасности без повторного развёртывания всего контракта.

4. Гибкость управления: децентрализованные автономные организации (DAO) могут использовать голосование сообщества для управления процессом обновления, обеспечивая прозрачность и участие заинтересованных сторон.

5. Упрощённые обновления: в отличие от традиционных контрактов, требующих сложного повторного развёртывания и миграции данных, обновляемые контракты упрощают процесс обновления, делая его более эффективным и удобным для пользователей.

Как работают обновляемые смарт-контракты?

Для создания обновляемого смарт-контракта наиболее распространённым методом является паттерн прокси. Этот подход разделяет логику контракта и его данные, позволяя разработчикам обновлять логику, сохраняя при этом то же хранилище данных.

Вот как это работает:

  1. Прокси-контракт: этот контракт хранит состояние (переменные состояния) и выступает в роли интерфейса для пользователей.

  2. Контракт реализации: этот контракт содержит исполняемый код (бизнес-логику).

  3. Делегирование: прокси-контракт делегирует вызовы функций контракту реализации с помощью delegatecall Ethereum. Это позволяет прокси выполнять код реализации, сохраняя при этом то же хранилище.

Когда требуется обновление, прокси-контракт перенаправляется на новый контракт реализации, что позволяет обновлять логику без изменения хранилища контракта или нарушения работы пользователей. Вот пример с использованием прокси-контракта DAO для иллюстрации этого процесса:

// 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)
        }
    }
}
Копировать
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");
Копировать

Важно отметить два момента: слот хранилища для адреса контракта реализации и слот хранилища для адреса администратора. Это намеренно выбранные уникальные идентификаторы, используемые для вычисления слота хранилища адреса реализации внутри прокси-контракта.

При использовании паттерна прокси с delegatecall (мы рассмотрим эту функцию подробнее чуть позже) контракт реализации работает в контексте хранилища прокси. Это означает:

  • Общее пространство хранилища: оба контракта используют одну и ту же структуру хранилища.

  • Риск перезаписи: без тщательного управления переменные в прокси-контракте могут быть перезаписаны контрактом реализации.

Использование хешированного уникального идентификатора обеспечивает:

  • Изоляцию: гарантируется, что слоты хранилища, используемые прокси, вряд ли будут конфликтовать с какими-либо переменными в контракте реализации.

  • Согласованность: один и тот же слот хранилища последовательно используется во всём контракте везде, где требуется адрес реализации.

constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }
Копировать

Ещё один важный аспект — конструктор прокси-контракта. Он включает параметр _implementation, что означает: контракт реализации должен быть развёрнут первым, а его адрес передаётся в качестве аргумента при развёртывании прокси-контракта.

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

    receive() external payable {
        _delegate(implementation());
    }
Копировать

Теперь рассмотрим функции fallback и receive — это функции Solidity, которые играют ключевую роль в перенаправлении вызовов и переводов Ether в контракт реализации. Они обрабатывают ситуации, когда контракт получает вызовы или средства, не соответствующие ни одной из явно определённых функций. Вот подробное объяснение того, что делают эти функции:

1. Функция fallback

  • Механизм fallback: функция fallback в Solidity — это специальная функция, которая выполняется при двух основных условиях:- когда вызывается функция, не существующая в контракте;

  • когда данные отправляются в контракт без соответствия какой-либо сигнатуре функции.

  • Делегирование: в контексте контракта DAOProxy функция fallback делегирует все такие вызовы текущему контракту реализации.

Как это работает в DAOProxy:

  • Обработка вызовов функций:- когда пользователь вызывает функцию, не определённую в прокси-контракте, срабатывает функция fallback.

  • Это распространённая ситуация, поскольку прокси-контракт в основном содержит административные функции, такие как upgradeTo и changeAdmin, а не функции бизнес-логики контракта реализации.

  • Делегирование в реализацию:- функция fallback вызывает _delegate(implementation()), перенаправляя вызов в контракт реализации.

  • _delegate — это внутренняя функция, которая использует delegatecall для выполнения кода реализации в контексте хранилища прокси.

  • Контекст выполнения:- все изменения состояния происходят в хранилище прокси.

  • msg.sender и msg.value остаются неизменными, сохраняя контекст исходного вызывающего.

2. Функция receive

  • Получение Ether: функция receive, введённая в Solidity 0.6.0, выполняется, когда контракт получает Ether без каких-либо сопутствующих данных (то есть когда msg.data пуст).

  • Делегирование переводов Ether: в контракте DAOProxy обычные переводы Ether делегируются контракту реализации.

Как это работает в DAOProxy:

  • Обработка переводов Ether:- когда кто-то отправляет Ether на адрес прокси-контракта без данных, срабатывает функция receive.

  • Это происходит через транзакцию, которая указывает только адрес получателя (to) и сумму (value в Ether), без вызова какой-либо конкретной функции.

  • Делегирование в реализацию:- как и функция fallback, receive вызывает _delegate(implementation()), перенаправляя перевод Ether в контракт реализации.

  • Затем контракт реализации определяет, как обрабатывать полученный Ether (например, обновление балансов, генерация событий).

 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())
            }
        }
    }
Копировать

Теперь рассмотрим функцию _delegate, которая позволяет прокси перенаправлять вызовы функций в контракт реализации, сохраняя контекст исходного вызова. Это позволяет обновлять логику контракта со временем без изменения его адреса или нарушения его состояния.

Остальные функции — это геттеры и сеттеры, которые помогают управлять переменными состояния нашего прокси-контракта. Для этого мы используем ключевое слово assembly, которое позволяет разработчикам включать низкоуровневый ассемблерный код непосредственно в свои контракты. Эта возможность обеспечивает прямой доступ к инструкциям Ethereum Virtual Machine (EVM), позволяя более точно контролировать поведение контракта, оптимизировать потребление газа и выполнять операции, которые либо недоступны напрямую, либо менее эффективны в высокоуровневом коде Solidity.

Теперь главный вопрос: как нам взаимодействовать с нашим смарт-контрактом реализации с помощью этого прокси-контракта?

Для этого нам нужен ABI смарт-контракта реализации. Поскольку прокси делегирует вызовы контракту реализации, вам нужен его ABI, чтобы знать, какие функции можно вызывать. Кроме того, при взаимодействии с блокчейном мы будем использовать адрес прокси-контракта, а не контракта реализации. Прокси хранит состояние и делегирует вызовы реализации, поэтому все взаимодействия должны проходить через прокси.

Представим, что у нас есть JavaScript-проект, и мы используем Ethers.js для взаимодействия с блокчейном. Вот как это будет выглядеть:

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);
Копировать

Таким образом, мы вызываем воображаемую функцию setValue в нашем контракте реализации через наш прокси-контракт.

Раз уж мы говорим об обновляемых контрактах, возникает ещё один важный вопрос: как обновить свои контракты?

Во-первых, только администратор (лицо, развернувшее прокси-контракт) может обновить контракт реализации. Для этого администратор должен сначала развернуть новую версию контракта реализации в блокчейне. После этого необходимо вызвать функцию upgradeTo в прокси-контракте, передав адрес нового контракта в качестве параметра. Это будет выглядеть следующим образом:

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

Навстречу будущему смарт-контрактов

Обновляемые смарт-контракты играют важную роль в разработке блокчейна, поскольку позволяют вносить изменения и улучшения после развёртывания. Используя паттерн прокси, разработчики могут бесшовно внедрять новые функции, исправлять ошибки и реагировать на изменяющиеся требования, не нарушая пользовательский опыт и не мигрируя активы.

Однако с большой силой приходит большая ответственность. Крайне важно подходить к обновляемым контрактам с глубоким пониманием их механики, последствий для безопасности и лучших практик. Обеспечение согласованности структуры хранилища, внедрение надёжного контроля доступа и тщательное тестирование обновлений — это необходимые шаги для защиты от потенциальных рисков.

Приступая к созданию обновляемых контрактов, рассмотрите возможность использования проверенных инструментов, таких как OpenZeppelin's Upgrades Plugin, для упрощения процесса и повышения безопасности. Удачного кодинга!

Переведено ИИ. Может содержать ошибки. Пожалуйста, всегда проверяйте информацию.

Оцените перевод

Автор статьи: