EVM часть 6: контракт с балансами

В предыдущей статье мы разобрались, как передавать аргументы функции на примере простых типов.  Давайте теперь напишем смарт-контракт, который хранит и устанавливает балансы.

Предыдущая статья.

Полный список статей по теме тут.

Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.

Контракт на Solidity

Если бы мы писали контракт на Solidity, то он выглядел бы так:

Мы уже знаем как работает селектор методов (см. вторую статью), а значит можем добавить в выбор новые методы. Мы также умеем читать и писать данные в маппинг. А в предыдущей статье научились передавать аргументы.

Метод записи баланса на ассемблере

  1. Прочитать размер из msg.data
  2. Вычислить размер аргументов. А это у нас размер msg.data минус 4 байта (идентификатор функции).
    msg.data выглядит примерно так:

    4 байта — идентификатор метода 32 байта — адрес 32 байта — баланс

    Теперь в стэке размер всех аргументов.

  3. Проверить, что размер аргументов равен 0x40. У нас два аргумента по 32 байта: адрес и сам баланс.

    Если размер аргументов не равен 0x40, то мы прыгаем на revert. Это условное обозначение метки, мы пропишем в конце ее код.

  4. Загрузить в стэк первый аргумент — адрес

  5. Вычислить адрес в маппинге (см.статью 4 — хранения маппинга). Для этого грузим в память по адресу 0x0 адрес маркера маппинга. У нас будет 0x0. И грузим также сам адрес. Все это для вычисления хэша. Помним, что в стэке у нас уже есть адрес. После загрузки данных в область памяти для работы с хэшами, вызываем KECCAK256 для вычисления хэша. И стэке у нас окажется хэш — т.е. адрес по которому мы будем писать значение маппинга.

  6. Загрузить в память баланс. Мы вычислили адрес, по которому записывать значение. Сейчас этот адрес в стэке. Теперь нам нужно загрузить в стэке само значение из msg.data, чтобы потом записать его в storage. Помним, что наше значение — баланс — это второй аргумент. А значит он будет в msg.data по смещению: (идентификатор функции) 0x04  + (адрес)  0x20 = 0x24.

  7. Записать в маппинг баланс по вычисленному адресу. На текущий момент в стэке у нас находятся
    Второй аргумент — значение баланса
    Адрес, куда записывать значение  в storage для маппинга

    Остается только записать в storage наш баланс.

  8. Теперь можем выходить из контракта.

  9. Теперь добавим код выхода из программы, если у нас не верное кол-во аргументов:

Все, с методом записи мы разобрались.

Метод чтения баланса

Метод чтения баланса будет писать проще, потому что какие-то куски будут аналогичными как и в записи. Например — проверка размера аргумента. Или вычисление адреса для чтения из мэппинга. Итак, наш алгоритм:

  1. Читаем размер msg.data
  2. Вычисляем размер аргументов в msg.data. А это у нас размер msg.data минус 4 байта (идентификатор функции).
    msg.data выглядит примерно так:

    4 байта — идентификатор метода 32 байта — адрес

    Теперь в стэке размер всех аргументов.

  3. Проверим, что размер аргументов равен 0x20 — у нас один аргумент — адрес. И его размер всегда равен 0x20.

    Когда мы писали метод записи, то уже написали код в котором происходит revert. Поэтому дублировать нет смысла.

  4. Теперь загрузим наш аргумент в стэк

  5. Вычисляем адрес, откуда будем читать. Комментировать не буду — код аналогичен, тому который мы написали в методе записи.

  6. Загружаем значение из storage в стэк

    Теперь в стэке у нас значение баланса.

  7. Давайте теперь запишем в память наш баланс. Помним, что записывать нужно в свободную память. А указатель свободной памяти находится по адресу 0x40. Поэтому сначала чтаем указатель свободной памяти, дублируем его в стэке (потребуется когда будем возвращать данные RETURN’ом), а затем пишем в память наш баланс.

    Посмотрите внимательно на махинации со стэком, которые мы делаем дабы сохранить в стэке указатель на свободную память. Тут три команды: DUP1, SWAP2, SWAP1. Как упражнение вы можете сравнить этот способ каким-нибудь другим. Например, возможно просто прочитать указатель на свободную память в две команды будет дешевле по газу?

  8. Теперь можно выходить. На стэке у нас указатель свободной памяти. Осталось указать размер возвращаемых данных и выйти.

Добавляем селекторы функций:

Из чего состоит селектор функций и для чего он нужен, мы уже знаем из второй статьи. Давайте возьмем типовой код и расширим его нашими двумя функциями. Типовой код селектора:

У нас две функции. Давайте вычислим их 4-байтовые идентификаторы c помощью этого сервиса. Т.е. вводим туда сигнатуры функции и забираем старшие 4 байта.

  • getBalance(address) => f8b2cb4f3943230388faeee074f1503714bff212640051caba01411868d14ae3 => 0xf8b2cb4f
  • setBalance(address,uint256) => e30443bc9bb3ffdc38bfb7be2087b5bd3bbaa5e972b5c0838e3741b6f1d20592 => 0xe30443bc

Мы можем заметить, что для вычисления идентификатора была использована сигнатура метода setBalance(address,uint256) . uint — это есть краткое название uint256. Компилятор при вызове будет использовать именно uint256.

А теперь заполним типовой код селектора в соответствие с нашими идентификаторами:

Обратите внимание, что мы убрали DUP1 во втором блоке сравнения. Из второй статьи мы помним, что эта инструкция дублирует идентификатор функции из msg.size, чтобы он остался к следующему блоку сравнения. Компилятор создает код в лоб. Мы же ручками можем убрать эту инструкцию в последнем блоке сравнения, ибо она тут бессмысленна.

Собираем код контракта

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

И есть не типовые конструкции: блок функции записи, блок функции чтения, блоки селектора функций.

Выходит, теперь мы можем собрать наш код. Давайте сделаем это.

Проверка работоспособности

Мы написали много интересного кода, а теперь осталось только проверить. Давайте напишем тестовый проект на hardhat:

  1. Создаем папку проекта и переходим в нее
  2. Устанавливаем hardhat: npm install —save-dev hardhat
  3. Инициализируем проект: npx hardhat init
    Выбираем typescript project
  4. В папке contracts создаем файл с нашим контрактом  BlockwitBalancesContract.sol:
  5. В папке test создаем файлик с нашими тестами BlockwitBalancesContract.ts:

    Это тест оригинального контракта.
  6. В папке test создадим файлик с тестами не оптимизированного байткода BlockwitBalancesContractBytecodeNotOptimized.sol:
  7. В папке test создадим файлик с тестами оптимизированного байткода BlockwitBalancesContractBytecodeOptimized.sol:
  8. Запускаем: npx hardhat test и должны получить успех. А один из тестов выведет байткод оптимизированного и не оптимизированного смарт-контрактов. А также размеры и стоимость по газу.

Готовы репозиторий можно склонировать отсюда — https://github.com/BlockWit/blockwit-balance-contract-field-optimization.

Резюме

Отлично! Мы написали свой первый полезный контракт на ассемблере. Давайте посмотрим газ и размер до и после:

Размер Газ
До оптимизации 722 203135
После оптимизации 150 80067

Выигрыш по размеру — почти в 5 раз. Выигрыш по газу — в 2 раза. На самом деле, это не очень объективные метрики. Дело в том, что в большинстве случаев контракты загружается один раз. Примеры постоянно создающихся контрактов — это пулы ликвидности.

Чаще всего  у контрактов используются методы. А значит и оптимизации разумно выполнять с оглядкой на то, какие методы будут использоваться наиболее часто.

Возьмем, к примеру, контракт популярной монеты. Такой как USDT. Контракт загружен единожды. А перемещения балансов могут достигать тысячи транзакций в день. Тем не менее, существенной оптимизации таких методов достичь крайне сложно, а иногда практически невозможно. Почему? Если вы посмотрите на описание стоимости выполнения инструкций, то большинство инструкций сами по себе стоят дешево ~3 единицы газа. А вот инструкции работы с storage стоят в сотни и даже тысячи раз больше:

SSTORE 20 000 за единицу информации (зависит от ситуации)
SLOAD 800 за единицу информации (зависит от ситуации)

Т.е. оптимизации самого кода вносят крайне малый вклад, если вы используете инструкции работы  со storage. А изменение состояния блокчейна — это и есть работа с инструкциями storage. Т.е. любой метод, который тарифицируется, использует инструкции работы со storage. Поэтому, существенной оптимизации можно достичь, оптимизируя логику работу со storage данными! 

Давайте приведем более объективные метрики — вызов метода setBalance:

До оптимизации После оптимизации
Первый вызов для одного адреса 44 806 43 913
Второй вызов для одного адреса 27 306 26 813

Как видите, наши оптимизации дают экономию всего-лишь ~1%.

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

Наш контракт может хранить балансы и записывать балансы. Однако, запись балансов никак не защищена. Т.е. любой пользователь может менять балансы. В следующей статье мы этим и займемся.

Предыдущая статья.

Полный список статей по теме тут.

Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.

Добавить комментарий