Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Автор: Lucas Aschenbach
Ссылка на оригинал
Комментарий от переводчика: статья
Комментарий от переводчика: статья по меркам Ethereum и языка Solidity относительно старая, аж 2018 года, но ряд идей и подходов будут полезны начинающим.
В настоящее время я работаю над Dapp, первый крупный этап разработки которого подходит к концу. Поскольку издержки на транзакции всегда являются большой проблемой для разработчиков, я хочу использовать эту статью, чтобы поделиться некоторыми соображениями, которые я получил за последние пару недель/месяцев в этой области с точки зрения оптимизации.
Ниже я привожу список методов оптимизации, которые вы можете применить при разработке своего смарт-контракта. Я начну с нескольких базовых, довольно обычных концепций, а затем по мере продвижения мы будем усложнять их.
Оглавление
Предпочтительные типы данных
Хранение значений в байткоде смарт-контракта
Упаковка переменных в один слот через SOLC
Упаковка переменных в один слот с помощью assembly
Конкатенация параметров функции
Доказательства Меркла для снижения нагрузки на хранилище
Смарт-контракты без состояния
Хранение данных на IPFS
1. Предпочтительные типы данных
На этот вопрос можно ответить всего несколькими словами: Используйте 256-битные переменные - uint256 и bytes32. Поначалу это может показаться немного нелогичным, но если задуматься о том, как работает виртуальная машина Ethereum (EVM), то все становится понятным. Каждый слот для хранения имеет 256 бит. Следовательно, если вы храните только uint8, EVM заполнит все недостающие цифры нулями - на это уходит газ. Кроме того, вычисления также без исключения производятся EVM в uint256, так что здесь любой тип, отличный от uint256, также придется преобразовывать.
Примечание: В целом, вы должны стремиться к такому размеру переменных, чтобы заполнялись все слоты памяти. В разделе "Упаковка переменных в один слот с помощью SOLC" станет более понятно, когда имеет смысл использовать переменные с размером менее 256 бит.
2. Хранение значений в байткоде смарт-контракта
Сравнительно дешевым способом хранения и считывания информации является непосредственное включение их в байткод смарт-контракта при его развертывании на блокчейне. Недостатком этого способа является то, что значение нельзя будет изменить впоследствии. Однако расход газа на загрузку и хранение данных значительно сокращается. Есть два возможных способа реализации этого:
Присоедините ключевое слово constant к объявлению переменной
Закодируйте переменную везде, где вы хотите ее использовать.
uint256 public v1;
uint256 public constant v2;
function calculate() returns (uint256 result) {
return v1 * v2 * 10000
}
Переменная v1 будет частью состояния смарт-контракта, тогда как v2, а также 1000 являются частью байткода смарт-контракта.
(Считывание v1 выполняется через операцию SLOAD, которая уже стоит 200 только за газ).
3. Упаковка переменных в один слот через SOLC
Когда вы сохраняете данные на блокчейне, для этого в фоновом режиме выполняется ассемблерная команда SSTORE. Это самая дорогая команда со стоимостью в 20 000 газов, поэтому мы должны стараться использовать ее как можно реже. Внутри структур количество выполняемых операций SSTORE можно сократить, просто переставив переменные, как в следующем примере:
struct Data {
uint64 a;
uint64 b;
uint128 c;
uint256 d;
}
Data public data;
constructor(uint64 _a, uint64 _b, uint128 _c, uint256 _d) public {
Data.a = _a;
Data.b = _b;
Data.c = _c;
Data.d = _d;
}
Обратите внимание, что внутри struct все переменные, которые в сумме могут заполнить 256-битный слот, упорядочены рядом друг с другом, чтобы компилятор мог впоследствии сложить их вместе (это также работает, если переменные занимают менее 256 бит). В этом конкретном примере операция SSTORE будет использована только дважды: один раз для хранения a, b и c, а другой раз для хранения d. То же самое относится и к переменным вне структур. Также следует помнить, что экономия от размещения нескольких переменных в одном слоте гораздо существеннее, чем от заполнения всего слота (предпочтительные типы данных).
Примечание: Не забудьте активировать оптимизацию для SOLC
4. Упаковка переменных в один слот с помощью assembly
Техника сложения переменных вместе, чтобы меньше выполнялось операций SSTORE, также может быть применена. Следующий код объединит 4 переменные типа uint64 в один единственный 256-битный слот.
Кодирование: Объединение переменных в одну.
function encode(uint64 _a, uint64 _b, uint64 _c, uint64 _d)
internal
pure
returns (bytes32 x) {
assembly {
let y := 0
mstore(0x20, _d)
mstore(0x18, _c)
mstore(0x10, _b)
mstore(0x8, _a)
x := mload(0x20)
}
}
Для чтения переменную необходимо декодировать, что можно осуществить с помощью второй функции.
Декодирование: Разделение переменной на ее начальные части.
function decode(bytes32 x)
internal
pure
returns (uint64 a, uint64 b, uint64 c, uint64 d) {
assembly {
d := x
mstore(0x18, x)
a := mload(0)
mstore(0x10, x)
b := mload(0)
mstore(0x8, x)
c := mload(0)
}
}
Сравнивая расход газа при этом методе и методе, описанном выше, вы заметите, что этот метод значительно дешевле по ряду причин:
Точность: при таком подходе вы можете делать практически все, что угодно в плане упаковки битов. Например, если вы уже знаете, что вам не понадобится последний бит переменной, вы можете легко провести оптимизацию, добавив однобитную переменную, которую вы используете вместе с 256-битной переменной.
Однократное чтение: Поскольку переменные хранятся в одном слоте, для получения всех переменных достаточно выполнить одну операцию загрузки. Это особенно полезно, если переменные будут использоваться совместно.
Так зачем вообще использовать предыдущий вариант? Если посмотреть на обе реализации, то становится ясно, что, используя assembly для en- и decoding переменных, мы отказываемся от читабельности, а значит, второй подход склонен к ошибкам. Кроме того, поскольку нам придется включать функции en- и decoding для каждого конкретного случая, стоимость развертывания значительно возрастет. Тем не менее, если вам действительно нужно снизить потребление газа вашими функциями, то этот способ - то, что нужно! (Чем больше переменных вы упаковываете в один слот, тем выше будет экономия по сравнению с другим методом).
5. Конкатенация параметров функции
Точно так же, как вы можете использовать функции en- и decode для оптимизации процесса чтения и хранения данных, вы можете использовать их для конкатенации параметров вызова функции, чтобы уменьшить нагрузку на данные вызова. Даже если это приведет к небольшому увеличению стоимости выполнения вашей транзакции, базовая комиссия будет снижена, так что в сумме вы выйдете дешевле.
6. Доказательства Меркла для снижения нагрузки на хранилище
В двух словах, Доказательство Меркла использует один фрагмент данных для того, чтобы доказать достоверность гораздо большего объема данных.
Если вы не знакомы с идеей доказательств Меркла, ознакомьтесь сначала с этими статьями, чтобы получить базовое понимание:
Преимущества, которые дает доказательство Меркла, поистине удивительны. Давайте рассмотрим пример:
Предположим, что мы хотим сохранить транзакцию покупки автомобиля, содержащую, скажем, 32 конфигурации. Создание структуры с 32 переменными, по одной на каждую конфигурацию, очень дорого! Вот тут-то и приходят на помощь Доказательства Меркла:
Сначала мы смотрим, какая информация будет запрашиваться вместе, и группируем 32 атрибута соответствующим образом. Предположим, что мы нашли 4 группы, каждая из которых содержит 8 конфигураций, чтобы все было просто.
Теперь мы создаем хэш для каждой из 4 групп из данных внутри них и снова группируем их в соответствии с предыдущим критерием.
Мы будем повторять это до тех пор, пока не останется только один хэш - Корень Меркла (hash1234).
Причина, по которой мы группируем их в зависимости от того, будут ли два элемента использоваться одновременно или нет, заключается в том, что для каждой проверки требуются и автоматически проверяются все элементы этой ветви (выделенные цветом на диаграмме). Это означает, что необходим только один процесс верификации. Например:
Доказательство Меркла для розового элемента
Все, что нам пришлось хранить в цепочке, это Корень Меркла, обычная 256-битная переменная (keccak256), и все же, если предположить, что производитель автомобилей пришлет вам машину не того цвета, вы сможете легко доказать, что это не та машина, которую вы заказывали.
bytes32 public merkleRoot;
//Let a,...,h be the orange base blocksfunction check
(
bytes32 hash4,
bytes32 hash12,
uint256 a,
uint32 b,
bytes32 c,
string d,
string e,
bool f,
uint256 g,
uint256 h
)
public view returns (bool success)
{
bytes32 hash3 = keccak256(abi.encodePacked(a, b, c, d, e, f, g, h));
bytes32 hash34 = keccak256(abi.encodePacked(hash3, hash4));
require(keccak256(abi.encodePacked(hash12, hash34)) == merkleRoot, "Wrong Element");
return true;
}
Имейте в виду: если к определенной переменной придется обращаться очень часто или изменять ее время от времени, возможно, имеет смысл просто хранить это конкретное значение обычным способом. Также следите за тем, чтобы ваши ветви не становились слишком большими, иначе вы превысите количество слотов стека, доступных для данной операции.
7. Смарт-контракты без состояния
Смарт-контракты без состояния используют тот факт, что данные транзакций и вызовы событий, полностью сохраняются в блокчейне. Поэтому вместо того, чтобы постоянно менять состояние смарт-контракта, все, что вам нужно сделать, - это отправить транзакцию и передать значение, которое вы хотите сохранить. Поскольку на операцию SSTORE обычно приходится большая часть затрат на транзакцию, смарт-контракты без состояния будут потреблять лишь малую часть газа по сравнению с смарт-контрактами с состоянием.
Применяя это к нашему примеру с автомобилем, мы отправим одну или две транзакции, в зависимости от того, можем ли мы конкатенировать параметры функции или нет (5. Конкатенирование параметров функции), которым мы передадим 32 конфигурации нашего автомобиля. Пока нам нужно только проверить информацию извне, это работает нормально и даже немного дешевле, чем Доказательства Меркла. Однако, с другой стороны, доступ к этой информации изнутри смарт-контракта практически невозможен при таком дизайне без жертв в плане централизации, стоимости или пользовательского опыта.
8. Хранение данных на IPFS
Сеть IPFS представляет собой децентрализованное хранилище данных, где каждый файл идентифицируется не по URL, а по хэшу его содержимого. Преимуществом здесь является то, что хэш не может быть изменен, следовательно, один конкретный хэш всегда будет указывать на один и тот же файл. Таким образом, мы можем просто транслировать наши данные в сеть IPFS, а затем сохранить соответствующий хэш в нашем смарт-контракте, чтобы впоследствии ссылаться на эту информацию.
Как и смарт-контракты без состояния, этот метод не позволяет реально использовать данные внутри смарт-контракта (это возможно с помощью Оракулов). Тем не менее, особенно если вы хотите хранить большие объемы данных, например, видео, этот подход, безусловно, является лучшим способом. (На заметку: Swarm, другая децентрализованная система хранения данных, возможно, также заслуживает внимания в качестве альтернативы IPFS).
Поскольку случаи использования 6, 7 и 8 довольно схожи, здесь мы подведем итог, когда какой из них использовать:
Деревья Меркла: Данные малого и среднего размера. Данные можно использовать внутри смарт-контракта. Изменение данных довольно сложно.
Смарт-контракты без состояния: Данные малого и среднего размера. Данные не могут быть использованы внутри смарт-контракта. Данные могут быть изменены.
IPFS: Большие объемы данных. Использование данных внутри смарт-контракта довольно трудоёмко. Изменение данных довольно сложно.