В предыдущей статье мы добавили в наш пустой контракт одно поле.
Изучили байткод, который получился в результате добавления поля. Выяснили, что в байткоде смарт-контракта присутствует селектор методов. И что для публичных полей Soldity создает одноименные методы.
Метод, который возвращал значение нашего поля был довольно запутанным и большим. Мы знаем, что компилятор создает код для общего случая. Поэтому кажется, что код нашего метода избыточен. Давайте оптимизируем код нашего метода и смарт-контракт.
Но прежде, я должен сообщить, что мы не ставим конечную цель — оптимизировать контракт так, чтобы он был дешевле. Наша конечная цель — разобраться в механизмах работы смарт-контракта на уровне виртуальной машины.
Предыдущая статья. Следующая статья.
Полный список статей по теме тут.
Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.
Оптимизация метода
Из всех участков кода, самый большой оказался участок метода чтения поля. Поэтому им мы и займемся. А остальные части контракта оставим как есть. Для начала этого хватит.
Перед оптимизацией запишем, сколько стоит залить наш смарт-контракт и какого он размера.
- Залить смарт-контракт стоит — 112,828
- Размер смарт-контракта — 204 байта
Приступим к оптимизации. Мы могли бы посмотреть на код метода и выкинуть лишние блоки кода. Но мы поступим по другому. Давайте подумаем, что реально должен делать метод и напишем свой код. Итак, метод должен:
- Прочитать значение поля из storage в стэк
- Записать значение из стэка в память
- Вернуть значение из памяти при выходе
Довольно просто, не так ли. Приступим
Прочитать значение из поля из storage в стэк
Мы помним из предыдущей статьи, куда в storage конструктор записывает наше значение:
1 2 3 |
0005 61 PUSH2 0x0309 - если перевести из HEX то получим наше число 777 0008 5F PUSH0 0009 55 SSTORE - записывает uint256 в storage по адресу 0 |
Наше значение конструктор записывает в storage по адресу 0x0. Вот оттуда мы его и прочитаем. Для этого нам потребуется команда чтения из storage — SLOAD — Storage load. Эта инструкция делает следующее:
- Забирает из стэка адрес
- Читает из storage по адресу из стэка 32 байта
- Помещает прочитанные данные в стэк
Поэтому для чтения нашего поля необходимо поместить в стэк адрес 0x0. Сделаем это командой PUSH0, а потом вызовем SLOAD. В результате получим простой код:
1 2 |
PUSH0 SLOAD |
Записать прочитанное значение из стэка в память
Мы помним, что куда попало в память писать мы не можем. Нам нужно писать именно в свободную память. А указатель на свободную память у нас хранится по адресу 0x40. Поэтому алгоритм действий у нас будет такой:
- Читаем указатель на свободную память в стэке
- Записываем в память значения поля из стэка по адресу из (1)
Прочитать указатель свободной памяти из адреса 0x40 в стэк мы можем командой MLOAD. Memory load.
Записать в память значение мы можем командой MSTORE. Memory store. Эта инструкция забирает из стэка два значения — адрес и само значение, которое нужно записать.
Давайте напишем код:
1 2 3 |
PUSH1 0x40 MLOAD MSTORE |
Поясним. Перед вызовом MSTORE мы ничего не помещаем в стэк, потому что до выполнения этого кода в стэке уже лежит значение поля. А адрес куда поместить значение будет помещен в стэк командой MLOAD.
Код рабочий и делает то, что нам нужен. Но написан немного не по правилам.
По правилам, после записи данных в память, мы должны были сместить указатель свободной памяти на 0x20. Он же должен указывать на свободную память. Но, поскольку мы в память больше ничего писать не будем, увеличивать его не обязательно.
Есть еще один момент. В следующей части, нам нужно будет возвращать данные из памяти. А значит нужен адрес, где эти данные хранятся. Давайте исправим наш код так, чтобы в стэке после выполнения нашего кода оставался адрес записанного значения.
1 2 3 4 5 6 |
PUSH1 0x40 MLOAD DUP1 SWAP2 SWAP1 MSTORE |
После выполнения этого кода на стэке у нас будет лежать указать на наши данные в памяти.
И теперь при необходимости можно увеличить указатель на свободную память на 0x20.
1 2 3 4 5 |
DUP1 PUSH1 0x20 ADD PUSH1 0x40 MSTORE |
Вернуть значение из памяти при выходе
Выход у нас осуществляется инструкцией RETURN. Эта инструкция ожидает в стэке: адрес откуда из памяти брать данные для возврата и размер данных.
В стэке у нас уже лежит указатель на данные в памяти. Осталось положить размер. Размер данных 32 байта. Или 0x20 в HEX.
Поэтому код получится такой:
1 2 3 |
PUSH 0x20 SWAP1 RETURN |
Полный код функции получения значения поля
Давайте соберем наш код. Код увеличения указателя свободной памяти мы включать не будем. Он нам тут не нужен. Но в будущих статья, возможно пригодиться. Это и есть шаблонный блок, подобием которого может пользоваться компилятор.
Итак, код нашего метода:
1 2 3 4 5 6 7 8 9 10 11 |
PUSH0 SLOAD PUSH1 0x40 MLOAD DUP1 SWAP2 SWAP1 MSTORE PUSH1 0x20 SWAP1 RETURN |
Как проверить работоспособность кода нашего метода
Зайдите на сервис evm playground и вставьте туда этот код
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
PUSH2 0x0309 PUSH0 SSTORE PUSH1 0x80 PUSH1 0x40 MSTORE JUMPDEST PUSH0 SLOAD PUSH1 0x40 MLOAD DUP1 SWAP2 SWAP1 MSTORE PUSH1 0x20 SWAP1 RETURN |
Впереди 4 строчки — это инициализация storage и memory: в storage мы кладем 0x0309, а в memory инициализируем указатель пустой памяти. Это необходимо для корректной работы нашего метода. Ведь мы не выполняем весь смарт-контракт, поэтому и требуется небольшая инициализация.
Теперь можете пройтись по шагам и убедиться, что наш код работает. В поле return value мы увидим 0x309.
Осталось собрать байткод нашего смарт-контракт вместе.
Сборка байткода контракта
Мы оптимизировали код нашего метода возврата поля a. Теперь давайте соберем байткод всего контракта. Напомню из чего состоит байткод смарт-контракта:
Чтобы исправить байткод нам потребуется:
- Взять оригинальный байткод
- Заменить код методов на наш оптимизированный
- Исправить размер кода при возврате из конструктора
- Исправить размер кода при копировании в конструкторе
В результате получим (без метаданных)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
0000 60 PUSH1 0x80 0002 60 PUSH1 0x40 0004 52 MSTORE 0005 61 PUSH2 0008 5F PUSH0 0009 55 SSTORE 000A 34 CALLVALUE 000B 80 DUP1 000C 15 ISZERO 000D 60 PUSH1 0x13 000F 57 *JUMPI 0010 5F PUSH0 0011 80 DUP1 0012 FD *REVERT 0013 5B JUMPDEST 0014 50 POP 0015 60 PUSH1 0x71 // код стал короче на 0x73 байт, заменим 0xac - 0x28 = 0x6c 0017 80 DUP1 0018 60 PUSH1 0x1f 001A 5F PUSH0 001B 39 CODECOPY 001C 5F PUSH0 001D F3 *RETURN 001E FE *ASSERT 001F 60 PUSH1 0x80 0021 60 PUSH1 0x40 0023 52 MSTORE 0024 34 CALLVALUE 0025 80 DUP1 0026 15 ISZERO 0027 60 PUSH1 0x0e 0029 57 *JUMPI 002A 5F PUSH0 002B 80 DUP1 002C FD *REVERT 002D 5B JUMPDEST 002E 50 POP 002F 60 PUSH1 0x04 0031 36 CALLDATASIZE 0032 10 LT 0033 60 PUSH1 0x26 0035 57 *JUMPI 0036 5F PUSH0 0037 35 CALLDATALOAD 0038 60 PUSH1 0xe0 003A 1C SHR 003B 80 DUP1 003C 63 PUSH4 0x0dbe671f 0041 14 EQ 0042 60 PUSH1 0x2a 0044 57 *JUMPI 0045 5B JUMPDEST 0046 5F PUSH0 0047 80 DUP1 0048 FD *REVERT // адрес старта - 0x2a 0049 5B JUMPDEST 004a 5F PUSH0 004b 54 SLOAD 004c 60 PUSH1 0x40 004e 51 MLOAD 004f 80 DUP1 0050 91 SWAP2 0051 90 SWAP1 0052 52 MSTORE 0053 60 PUSH1 0x20 0055 90 SWAP1 0056 F3 RETURN |
А байткод станет таким:
0x60806040526103095f553480156013575f80fd5b50606c8060
1f5f395ff3fe6080604052348015600e575f80fd5b50600436
106026575f3560e01c80630dbe671f14602a575b5f80fd5B5F5
460405180919052602090F3a264697066735822122052b6
89f2eb123eb4fe4ead878d0b4a6224c0554c640c36015c4e5a
cc61d7c9d364736f6c63430008190033
- Жирным выделены изменившееся данные
- Сиреневым метаданные
Осталось протестировать!
Тестирование
Для тестирования будем использовать фреймворк hardhat. Предполагается, что читатель знаком с ним или способен установить по документации. Если вкратце, то:
- Создаем папку проекта и переходим в нее
- Устанавливаем hardhat: npm install —save-dev hardhat
- Инициализируем проект: npx hardhat init
Выбираем typescript project - В папке contracts создаем файл с нашим контрактом BlockwitEmptyContract.sol:
123456789// SPDX-License-Identifier: GPL-3.0pragma solidity ^0.8.24;contract BlockwitEmptyContract {uint public a = 777;} - В папке test создаем файлик с нашими тестами BlockwitEmptyContract.ts:
123456789101112131415161718192021222324import {loadFixture,} from "@nomicfoundation/hardhat-toolbox/network-helpers";import {expect} from "chai";import hre from "hardhat";describe("BlockwitEmptyContract", function () {async function deploy() {const [owner, otherAccount] = await hre.ethers.getSigners();const BlockwitEmptyContract = await hre.ethers.getContractFactory("BlockwitEmptyContract");const blockwitEmptyContract = await BlockwitEmptyContract.deploy();return {blockwitEmptyContract, owner, otherAccount};}describe("Deployment", function () {it("Should get the right field a value", async function () {const {blockwitEmptyContract, owner, otherAccount} = await loadFixture(deploy);expect(await blockwitEmptyContract.a()).to.equal(777);});});});
Это тест оригинального смарт-контракта. - В папке test создадим файлик с тестами не оптимизированного байткода BlockwitEmptyContractNotOptimized.sol:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172import {loadFixture,} from "@nomicfoundation/hardhat-toolbox/network-helpers";import {expect} from "chai";import hre from "hardhat";describe("BlockwitEmptyContractBytecodeWayNotOptimized", function () {async function deploy() {const [owner, otherAccount] = await hre.ethers.getSigners();const bytecode = "0x" +// constructor// - pointer to empty memory initialization"6080604052" +// - write filed "a" value to storage"6103095f55" +// - checks whether sent ether"3480156013575f80fd" +// - copy code to memory and return"5b5060ac80601f5f395ff3fe" +// smart-contract// - pointer to empty memory initialization"6080604052" +// - checks whether sent ether"348015600e575f80fd" +// - checks whether it 4-bytes function identifier call or not"5b5060043610602657" +// - methods selector"5f3560e01c80630dbe671f14602a57" +// - revert in case of eth sent or 4-bytes function selector not found"5b5f80fd" +// - functions code"5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fe" +// metadata"a2646970667358221220" +"11b00437b0b3511bbfcf815f04a652a2ca60951acd4e28803b30bdf9cf80918f" +"64736f6c6343" +// - major version"0008" +// - minor version"19" +"0033";console.log(bytecode);console.log("Bytecode size: " + bytecode.length/2);const txReceipt = await owner.sendTransaction({data: bytecode})const txResult = await txReceipt.wait();if (txResult?.contractAddress == null)throw "Returned empty address";console.log("Gas used: " + txResult.gasUsed);const contractAddress = txResult.contractAddress;const BlockwitEmptyContract = await hre.ethers.getContractFactory("BlockwitEmptyContract");const blockwitEmptyContract = await BlockwitEmptyContract.attach(contractAddress);return {blockwitEmptyContract, owner, otherAccount};}describe("Deployment", function () {it("Should get the right field a value", async function () {const {blockwitEmptyContract, owner, otherAccount} = await loadFixture(deploy);expect(await blockwitEmptyContract.a()).to.equal(777);});});}); - В папке test создаем файлик с нашими тестами BlockwitEmptyContractOptimized.ts:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273import {loadFixture,} from "@nomicfoundation/hardhat-toolbox/network-helpers";import {expect} from "chai";import hre from "hardhat";describe("BlockwitEmptyContractBytecodeWayOptimized", function () {async function deploy() {const [owner, otherAccount] = await hre.ethers.getSigners();const bytecode = "0x" +// constructor// - pointer to empty memory initialization"6080604052" +// - write filed "a" value to storage"6103095f55" +// - checks whether sent ether"3480156013575f80fd" +// - copy code to memory and return"5b50606c80601f5f395ff3fe" +// smart-contract// - pointer to empty memory initialization"6080604052" +// - checks whether sent ether"348015600e575f80fd" +// - checks whether it 4-bytes function identifier call or not"5b5060043610602657" +// - methods selector"5f3560e01c80630dbe671f14602a57" +// - revert in case of eth sent or 4-bytes function selector not found"5b5f80fd" +// - functions code//"5B5F5460405180919052602090F3" +// metadata"a2646970667358221220" +"52b689f2eb123eb4fe4ead878d0b4a6224c0554c640c36015c4e5acc61d7c9d3" +"64736f6c6343" +// - major version"0008" +// - minor version"19" +"0033";console.log(bytecode);console.log("Bytecode size: " + bytecode.length/2);const txReceipt = await owner.sendTransaction({data: bytecode})const txResult = await txReceipt.wait();if (txResult?.contractAddress == null)throw "Returned empty address";console.log("Gas used: " + txResult.gasUsed);const contractAddress = txResult.contractAddress;const BlockwitEmptyContract = await hre.ethers.getContractFactory("BlockwitEmptyContract");const blockwitEmptyContract = await BlockwitEmptyContract.attach(contractAddress);return {blockwitEmptyContract, owner, otherAccount};}describe("Deployment", function () {it("Should get the right field a value", async function () {const {blockwitEmptyContract, owner, otherAccount} = await loadFixture(deploy);expect(await blockwitEmptyContract.a()).to.equal(777);});});});
В переменной bytecode я записал наш оптимизированный байткод с комментариями. - Запускаем: npx hardhat test и должны получить успех. А один из тестов выведет байткод оптимизированного смарт-контракта
Готовы репозиторий можно склонировать отсюда — https://github.com/BlockWit/blockwit-empty-contract-field-optimization.
Резюме
В этой статье мы оптимизировали наш контракт.
Давайте посмотрим сколько мы сэкономили:
До оптимизации | После оптимизации | |
Размер смарт-контракта | 204 | 141 |
Стоимость по газу | 112 828 | 99 004 |
Мы сократили затраты на смарт-контракт на 13,000 газа! Можно сказать, что наша оптимизация успешна.
Конечно, в случае контракта с одним полем, как наш, мы можем и дальше оптимизировать. К примеру, выкинуть инициализации указателя на пустую память, ведь мы заранее знаем где будут располагаться наши данные. Почистить селектор — у нас всего одна функция. И так далее. Однако, в большинстве контрактов эти блоки нам потребуются, поэтому пока нам достаточно нашего выигрыша.
Предыдущая статья. Следующая статья.
Полный список статей по теме тут.
Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.