Почти перед каждым программистом, который пишет смарт-контракты Ethereum встают вопросы: «Что делать, если нужно будет расширить функционал контрактов? Как быть, если в контракте найдется баг, который повлечет за собой потерю средств? Что делать, если обнаружится уязвимость в компиляторе solidity (что бывало уже не раз)?» Ведь, контракты, которые мы загружаем в сеть, не могут быть изменены. Поначалу довольно сложно осознать: как это код нельзя обновить? Почему? Но в этом отчасти и сила смарт-контрактов Ethereum — пользователи, возможно, меньше бы стали доверять контрактам, которые можно менять.
Постараемся разобрать несколько подходов, которые все же позволяют менять смарт-контракты.
Эта статья рассчитана на тех, кто обладает хотя бы базовыми навыками программирования на языке solidity и понимает основные принципы работы сети Ethereum.
При этом можно сохранить адреса активных в данный момент контрактов в storage какого-либо из контрактов. Нередко выделяют какой-то один контракт, который отвечает за хранение и изменение ссылок на части всей системы.
Как пример можно привести контракт распродажи токенов, в котором четко не прописаны правила вычисления количества токенов, которые нужно отправить на кошелек с которого пришли Этеры. Вычислением количества может заниматься отдельный контракт, который мы сможем подменять в случае надобности. Не будем долго останавливаться на этом варианте, потому что подобный подход часто используется не только в solidity.
Одним из главных минусов такого подхода является то, что никак нельзя изменить интерфейс какого-то контракта, который является внешним для всей системы. Нельзя добавить или удалить функцию.
В EIP-7 была предложена и реализована инструкция, которая позволяет вызывать код из другого контракта, но контекст вызова остается тем же самым, что и у текущего контракта. То есть, вызываемый контракт будет писать в storage вызывающего контракта, msg.sender и msg.value остаются такими же, как изначально.
В сети можно найти несколько примеров реализации данного механизма. Все они включают использование solidity assembly. Без assembly невозможно добиться возвращения какого-либо значения из delegatecall.
Основная идея всех методов, которые используют delegatecall для проксирования — реализация fallback функции. В ней необходимо прочесть calldata и передать дальше через delegatecall.
Поближе посмотрим на несколько примеров реализации:
Рассмотрим вопрос использования газа. Проведенное тестировние показывает, что все 3 приведенных метода увеличивают использование газа на значение от 1000 до 1500. Много это или мало? Это примерно 2% от более-менее средней стоимости транзакции, которая будет изменять storage.
К сожалению, использование данных методик ограничено. Во-первых, чтобы такое обновление контрактов работало, нельзя менять структуру хранения данных в контракте (нельзя переставлять местами поля, удалять поля). В новые версии контракта поля можно добавлять.
Также необходимо очень аккуратно разграничивать доступ к функции, которая меняет адрес активного контракта.
Немаловажным фактом является то, что доверие пользователей к контракту будет меньше, чем к такому же неизменяемому. С другой стороны можно предусмотреть тестовый период времени, в течение которого новую версию контракта можно откатить, а после которого версия контракта будет зафиксирована и меняться больше не сможет.
Несколько контрактов, которые помогут сделать обновление проще и надежнее.
Upgradeable — в этом контракте реализована проверка на то, что поле target (адрес активной версии контракта) хранится в том же самом слоте, что и в текущей версии.
Аналогично можно реализовать проверки и на другие поля storage (пример можно посмотреть в Target.sol)
Если вы планируете реализовать Upgradeable контракты, то обязательно посмотрите на тесты для контракта Upgradeable.
Перед деплоем подобных контрактов в сеть, нужно обязательно тестировать все варианты. Иначе после очередного обновления можно остаться без функционирующего контракта и без возможности обновления.
Постараемся разобрать несколько подходов, которые все же позволяют менять смарт-контракты.
Эта статья рассчитана на тех, кто обладает хотя бы базовыми навыками программирования на языке solidity и понимает основные принципы работы сети Ethereum.
Разделить смарт-контракт на несколько связанных контрактов
При этом можно сохранить адреса активных в данный момент контрактов в storage какого-либо из контрактов. Нередко выделяют какой-то один контракт, который отвечает за хранение и изменение ссылок на части всей системы.
Как пример можно привести контракт распродажи токенов, в котором четко не прописаны правила вычисления количества токенов, которые нужно отправить на кошелек с которого пришли Этеры. Вычислением количества может заниматься отдельный контракт, который мы сможем подменять в случае надобности. Не будем долго останавливаться на этом варианте, потому что подобный подход часто используется не только в solidity.
Одним из главных минусов такого подхода является то, что никак нельзя изменить интерфейс какого-то контракта, который является внешним для всей системы. Нельзя добавить или удалить функцию.
Использовать delegatecall для проксирования вызова в другой контракт
В EIP-7 была предложена и реализована инструкция, которая позволяет вызывать код из другого контракта, но контекст вызова остается тем же самым, что и у текущего контракта. То есть, вызываемый контракт будет писать в storage вызывающего контракта, msg.sender и msg.value остаются такими же, как изначально.
В сети можно найти несколько примеров реализации данного механизма. Все они включают использование solidity assembly. Без assembly невозможно добиться возвращения какого-либо значения из delegatecall.
Основная идея всех методов, которые используют delegatecall для проксирования — реализация fallback функции. В ней необходимо прочесть calldata и передать дальше через delegatecall.
Поближе посмотрим на несколько примеров реализации:
- Upgradeable хранит размеры возвращаемых значений в mapping.
Вот реализация fallback функции отсюда:
bytes4 sig; assembly { sig := calldataload(0) } var len = _sizes[sig]; var target = _dest; assembly { // return _dest.delegatecall(msg.data) calldatacopy(0x0, 0x0, calldatasize) delegatecall(sub(gas, 10000), target, 0x0, calldatasize, 0, len) return(0, len) }
При этом размер возвращаемого значения (в байтах) хранится в mapping _sizes. Это поле в storage необходимо заполнять при обновлении контракта.
Минусом данного подхода является то, что размер возвращаемого значения жестко привязан к сигнатуре вызываемой функции, то есть вернуть строку произвольного размера или массив байтов не получится.
Кроме того, обращение к storage довольно дорого стоит. А в данном случае у нас будет аж два обращения к storage: когда мы обращаемся к полю _dest и когда мы обращаемся к полю _size.
- EVM assembly tricks: всегда использовать размер ответа, равный 32 байтам.
Код очень похож на предыдущий пример, но всегда используется размер ответа, равный 32 байтам. Это довольно взвешенное решение. Во-первых, большинство типов в solidity умещаются именно в 32 байта, во-вторых, не обращаясь лишний раз к storage, мы экономим довольно приличное количество газа. Позже оценим, сколько примерно газа тратится в разных вариантах реализации - Использование новых инструкций resultdatasize и resultdatacopy
Эти инструкции появились в основной сети Ethereum только после последнего хардфорка (Byzantium — 17 октября 2017 года).
Инструкции позволяют получить размер ответа, который возвращен из call/delegatecall, а также скопировать сам ответ в память. То есть, мы получили возможность реализовать полноценный прокси для любых размеров returndata.
Вот итоговый assembly код:
assembly { let _target := sload(0) calldatacopy(0x0, 0x0, calldatasize) let retval := delegatecall(gas, _target, 0x0, calldatasize, 0x0, 0) let returnsize := returndatasize returndatacopy(0x0, 0x0, returnsize) switch retval case 0 {revert(0, 0)} default {return (0, returnsize)} }
Рассмотрим вопрос использования газа. Проведенное тестировние показывает, что все 3 приведенных метода увеличивают использование газа на значение от 1000 до 1500. Много это или мало? Это примерно 2% от более-менее средней стоимости транзакции, которая будет изменять storage.
Сложности в использовании
К сожалению, использование данных методик ограничено. Во-первых, чтобы такое обновление контрактов работало, нельзя менять структуру хранения данных в контракте (нельзя переставлять местами поля, удалять поля). В новые версии контракта поля можно добавлять.
Также необходимо очень аккуратно разграничивать доступ к функции, которая меняет адрес активного контракта.
Немаловажным фактом является то, что доверие пользователей к контракту будет меньше, чем к такому же неизменяемому. С другой стороны можно предусмотреть тестовый период времени, в течение которого новую версию контракта можно откатить, а после которого версия контракта будет зафиксирована и меняться больше не сможет.
Примеры реализации обновлений
Несколько контрактов, которые помогут сделать обновление проще и надежнее.
Upgradeable — в этом контракте реализована проверка на то, что поле target (адрес активной версии контракта) хранится в том же самом слоте, что и в текущей версии.
Аналогично можно реализовать проверки и на другие поля storage (пример можно посмотреть в Target.sol)
Если вы планируете реализовать Upgradeable контракты, то обязательно посмотрите на тесты для контракта Upgradeable.
Перед деплоем подобных контрактов в сеть, нужно обязательно тестировать все варианты. Иначе после очередного обновления можно остаться без функционирующего контракта и без возможности обновления.
Legion21
Ни одного коммета? странно…