Почти перед каждым программистом, который пишет смарт-контракты Ethereum встают вопросы: «Что делать, если нужно будет расширить функционал контрактов? Как быть, если в контракте найдется баг, который повлечет за собой потерю средств? Что делать, если обнаружится уязвимость в компиляторе solidity (что бывало уже не раз)?» Ведь, контракты, которые мы загружаем в сеть, не могут быть изменены. Поначалу довольно сложно осознать: как это код нельзя обновить? Почему? Но в этом отчасти и сила смарт-контрактов Ethereum — пользователи, возможно, меньше бы стали доверять контрактам, которые можно менять.

Постараемся разобрать несколько подходов, которые все же позволяют менять смарт-контракты.

Эта статья рассчитана на тех, кто обладает хотя бы базовыми навыками программирования на языке solidity и понимает основные принципы работы сети Ethereum.

Разделить смарт-контракт на несколько связанных контрактов


При этом можно сохранить адреса активных в данный момент контрактов в storage какого-либо из контрактов. Нередко выделяют какой-то один контракт, который отвечает за хранение и изменение ссылок на части всей системы.

Как пример можно привести контракт распродажи токенов, в котором четко не прописаны правила вычисления количества токенов, которые нужно отправить на кошелек с которого пришли Этеры. Вычислением количества может заниматься отдельный контракт, который мы сможем подменять в случае надобности. Не будем долго останавливаться на этом варианте, потому что подобный подход часто используется не только в solidity.

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

Использовать delegatecall для проксирования вызова в другой контракт


В EIP-7 была предложена и реализована инструкция, которая позволяет вызывать код из другого контракта, но контекст вызова остается тем же самым, что и у текущего контракта. То есть, вызываемый контракт будет писать в storage вызывающего контракта, msg.sender и msg.value остаются такими же, как изначально.

В сети можно найти несколько примеров реализации данного механизма. Все они включают использование solidity assembly. Без assembly невозможно добиться возвращения какого-либо значения из delegatecall.

Основная идея всех методов, которые используют delegatecall для проксирования — реализация fallback функции. В ней необходимо прочесть calldata и передать дальше через delegatecall.
Поближе посмотрим на несколько примеров реализации:

  1. 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.
  2. EVM assembly tricks: всегда использовать размер ответа, равный 32 байтам.

    Код очень похож на предыдущий пример, но всегда используется размер ответа, равный 32 байтам. Это довольно взвешенное решение. Во-первых, большинство типов в solidity умещаются именно в 32 байта, во-вторых, не обращаясь лишний раз к storage, мы экономим довольно приличное количество газа. Позже оценим, сколько примерно газа тратится в разных вариантах реализации
  3. Использование новых инструкций 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.

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

Комментарии (1)


  1. Legion21
    13.11.2017 17:54

    Ни одного коммета? странно…