• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Изучайте код EVM, пишите смарт-контракты лучше

вавилонец

CPU register
Пользователь
Регистрация
17.06.2021
Сообщения
1 116
Реакции
1 265
ОРИГИНАЛЬНАЯ СТАТЬЯ
ПЕРЕВЕДЕНО СПЕЦИАЛЬНО ДЛЯ xss.pro
Шекелей пачку Jolah Molivski


Ваши хорошие привычки разработчика приводят к тому, что вы пишете неэффективные смарт-контракты. Для типичных языков программирования единственными затратами, связанными с изменением состояния и вычислениями, являются время и электроэнергия, используемая оборудованием. Однако для EVM-совместимых языков, таких как Solidity и Vyper, эти действия явно стоят денег . Эта стоимость выражается в собственной валюте блокчейна (ETH для Etheruem, AVAX для Avalanche и т. д.), которую можно рассматривать как товар, используемый для оплаты этих действий. Стоимость вычислений, переходов между состояниями и хранения называется газом . Газ используется для определения приоритетов транзакций, в качестве сопротивления Сивиллы и для предотвращения атак, связанных с проблемой остановки .
Эти нетипичные затраты приводят к шаблонам проектирования программного обеспечения, которые кажутся неэффективными и странными в типичных языках программирования. Чтобы уметь распознавать эти закономерности и понимать, почему они приводят к экономической эффективности, вы должны сначала иметь базовое представление о виртуальной машине Ethereum, то есть EVM.

Что такое EVM?

Блокчейн — это конечный автомат . Блокчейны постепенно выполняют транзакции, которые переходят в какое-то новое состояние. Следовательно, каждая транзакция в блокчейне — это переход состояния.
Простые блокчейны, такие как Биткойн, изначально поддерживают только простые переводы. Напротив, цепочки, совместимые со смарт-контрактами, такие как Ethereum, реализуют два типа учетных записей: внешние учетные записи и контрактные учетные записи, чтобы поддерживать сложную логику.
Внешние учетные записи контролируются пользователями с помощью закрытых ключей и не имеют связанного с ними кода, в то время как контрактные учетные записи контролируются исключительно связанным с ними кодом. Код EVM хранится в виде байт в виртуальном ROM.
EVM обрабатывает выполнение и обработку всех транзакций в базовой цепочке блоков. Это стековая машина, в которой размер каждого элемента стека составляет 256 бит или 32 байта. EVM встроен в каждый узел Ethereum и отвечает за выполнение байт-кода контракта.
EVM хранит данные как в Storage / долговременная память, точнее постояная, так как при развертывании контракта записываеется в блокчейн и, как известно, что туда попало - там навсегда и осталось / , так и в memory / типа оперативной памяти / . Storage используется для постоянного хранения данных, а memory используется для хранения данных во время вызовов функций. Вы также можете передавать аргументы функции как данные вызова, которые действуют аналогично выделению в память, за исключением того, что данные не поддаются изменению.
Смарт-контракты пишутся на языках более высокого уровня, таких как Solidity, Vyper или Yul, и впоследствии разбиваются на байт-код EVM с помощью компилятора. Однако бывают случаи, когда более эффективно использовать байт-код непосредственно в коде.

1661912063278.png


Байт-код EVM записывается в шестнадцатеричном формате. Это язык, который виртуальная машина может интерпретировать. Это чем-то похоже на то, как процессоры могут интерпретировать только машинный код.

1661912182644.png
Пример байт-кода Solidity

Что такое опкоды EVM?

Весь байт-код Ethereum можно разбить на ряд операндов и кодов операций. Коды операций — это предопределенные инструкции, которые EVM интерпретирует и впоследствии может выполнить. Например, код операции ADD представлен как 0x01 в байт-коде EVM. Он удаляет два элемента из стека и помещает результат.
Количество элементов, удаляемых из стека и помещаемых в стек, зависит от кода операции. Например, имеется тридцать два кода операции PUSH: от PUSH1 до PUSH32. PUSH* добавляет в стек * байтовый элемент размером от 0 до 32 байт. Он не удаляет никаких значений из стека и добавляет одно значение. Напротив, код операции ADDMOD представляет операцию сложения по модулю и удаляет три элемента из стека, а затем помещает результат в стек. Примечательно, что только коды операций PUSH поставляются с операндами.

1661912376996.png
Коды операций предыдущего примера байт-кода

Каждый код операции представляет собой один байт и имеет разную стоимость. В зависимости от кода операции эти затраты либо фиксированы, либо определяются по формуле. Например, код операции ADD стоит 3 газа. А SSTORE, который сохраняет данные в хранилище, стоит 20 000 газа, когда значение хранилища устанавливается на ненулевое значение, и стоит 5000 газа, когда значение переменной хранилища установлено равным нулю или остается неизменным.

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

Почему важно понимать коды операций EVM?

Понимание кодов операций EVM чрезвычайно важно для минимизации потребления газа и, в свою очередь, снижения затрат для вашего конечного пользователя. Поскольку стоимость, связанная с кодами операций EVM, произвольна, различные шаблоны кодирования, позволяющие достичь одного и того же результата, могут привести к значительному увеличению затрат. Знание того, какие коды операций являются самыми дорогими, поможет вам свести к минимуму и избежать их использования, когда это не нужно. Просмотрите документацию Ethereum для получения полного списка кодов операций EVM и связанных с ними затрат на газ.

Использование умножения вместо возведения в степень: MUL против EXP

Код операции MUL стоит 5 газа и используется для выполнения умножения. Например, арифметическое действие 10 * 10 будет стоить 5 газа.
Код операции EXP используется для возведения в степень, а его стоимость газа определяется по формуле: если показатель степени равен нулю, код операции стоит 10 газа. Однако, если показатель степени больше нуля, это стоит 10 газа + 50-кратное количество байтов в показателе степени.
Поскольку байт состоит из 8 бит, один байт используется для представления значений от 0 до 2⁸-1, два байта используются для представления значений от 2⁸ до 2¹⁶-1 и т. д. Например, 10¹⁸ будет стоить 10 + 50 * 1 = 60 газа, а 10³⁰⁰ будет стоить 10 + 50 * 2 = 160 газа, поскольку для представления 18 требуется один байт, а для представления 300 — два байта.

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

Код:
contract squareExample {uint256 x;constructor (uint256 _x) {
       x = _x;
 }
function inefficcientSquare() external {
   x = x**2;
 }
function efficcientSquare() external {
     x = x * x;
 }
 }

И inefficcientSquare, и efficcientSquare устанавливают переменную состояния x в квадрат самой себя. Однако арифметика inefficcientSquare стоит 10 + 1 * 50 = 60 газа, а арифметика efficcientSquare стоит 5 газа.
По причинам, помимо указанных выше затрат на арифметику, inefficcientSquare стоит примерно на 200 газа больше, чем efficcientSquare среднем .

1661912936167.png


Кэширование данных: SLOAD и MLOAD

Хорошо известно, что кэширование данных приводит к гораздо более высокой производительности при масштабировании. Однако кэширование данных на EVM чрезвычайно важно и приведет к существенной экономии газа даже при небольшом количестве операций.

Коды операций SLOAD и MLOAD используются для загрузки данных из хранилища и памяти. MLOAD всегда стоит 3 газа, в то время как стоимость SLOAD определяется по формуле: SLOAD стоит 2100 газа для первоначального доступа к значению во время транзакции и стоит 100 газа для каждого последующего доступа. Это означает, что загружать данные из memory на ≥97% дешевле, чем из storage.

Ниже приведен пример кода и потенциальная экономия газа:

Код:
contract storageExample {uint256 sumOfArray;
    function inefficcientSum(uint256 [] memory _array) public {
    for(uint256 i; i < _array.length; i++) {
            sumOfArray += _array[i];
        }
    }
    function efficcientSum(uint256 [] memory _array) public {
      uint256 tempVar;   for(uint256 i; i < _array.length; i++) {
            tempVar += _array[i];
        }   sumOfArray = tempVar;
    }
}

Контракт storageExample имеет две функции: inefficcientSum и efficcientSum.

Обе функции принимают _array , представляющий собой массив целых чисел без знака. Они оба устанавливают переменную состояния контракта, sumOfArray , в сумму значений в _array .
inefficcientSum использует саму переменную состояния для своих вычислений. Помните, что переменные состояния, такие как sumOfArray , хранятся в хранилище .
efficcientSum создает в памяти временную переменную tempVar , которая используется для вычисления суммы значений в _array . Затем sumOfArray присваивается значению tempVar .
efficcientSum более чем на 50 % эффективнее по газу, чем inefficcientSum, при передаче массива только из 10 целых чисел без знака.

1661913178075.png


Эта эффективность зависит от количества вычислений: efficcientSum - 300 % эффективнее по газу, чем inefficcientSum, при передаче массива из 100 целых чисел без знака.


1661913249018.png


Избегайте объектно-ориентированного программирования: код операции CREATE

Код операции CREATE используется при создании новой учетной записи с соответствующим кодом (например, смарт-контракт). Он стоит не менее 32 000 газа и является самым дорогим кодом операции в EVM.
По возможности лучше свести к минимуму количество используемых смарт-контрактов. Это отличается от типичного объектно-ориентированного программирования, в котором разделение классов поощряется для повторного использования и ясности.
Ниже приведен код для создания «хранилища» с использованием объектно-ориентированного подхода. Каждое хранилище содержит uint256, который задается в его конструкторе.

Код:
contract Vault {    
    uint256 private x;
    constructor(uint256 _x) { 
    x = _x;
    }    
   function getValue() external view returns (uint256) {
       return x;}
   } // end of Vaultinterface IVault {    function getValue() external view returns (uint256);} // end of IVault

contract InefficcientVaults {    
        address[] public factory;    
    constructor() {}
    function createVault(uint256 _x) external {
        address _vaultAddress = address(new Vault(_x));
        factory.push(_vaultAddress);
}
    function getVaultValue(uint256 vaultId) external view returns (uint256) {
        address _vaultAddress = factory[vaultId];
        IVault _vault = IVault(_vaultAddress);
        return _vault.getValue();
    }
}


Каждый раз, когда вызывается createVault() , создается новый смарт-контракт , хранящееся в Vault , определяется аргументом, переданным в createVault(). Затем адрес нового Vault сохраняется в массиве factory.

Теперь вот код, который выполняет ту же цель, но использует сопоставление / mapping / вместо создания нового смарт-контракта:

Код:
contract EfficcientVaults {// vaultId => vaultValue
mapping (uint256 => uint256) public vaultIdToVaultValue;// the next vault's id
uint256 nextVaultId;function createVault(uint256 _x) external {
    vaultIdToVaultValue[nextVaultId] = _x;
    nextVaultId++;
}function getVaultValue(uint256 vaultId) external view returns (uint256) {
    return vaultIdToVaultValue[vaultId];
}} // end of EfficcientVaults

При каждом вызове createVault() его аргумент сохраняется в отображении, а его идентификатор определяется переменной состояния nextVaultId, которая увеличивается при каждом вызове createVault() .

Эта разница в реализации приводит к резкому снижению затрат на газ.

1661914189735.png


Функция createVault() от EfficcientVaults на 61% эффективнее и стоит примерно на 76 300 единиц газа меньше, чем в среднем у InefficcientVaults.

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

Хранение данных: SSTORE

SSTORE — это код операции EVM для сохранения данных в хранилище. SSTORE стоит 20 000 газа при установке значения хранилища ненулевым с нуля и 5000 газа, когда значение хранилища установлено равным нулю.
Из-за этой стоимости хранение данных в сети крайне неэффективно и дорого. Его следует избегать, когда это возможно.

Эта практика наиболее распространена с NFT. Разработчики будут хранить метаданные NFT (его изображение, атрибуты и т. д.) в децентрализованной сети хранения, такой как Arweave или IPFS, вместо того, чтобы хранить их в блокчейне. Единственные данные, которые хранятся в сети, — это ссылка на метаданные в соответствующей децентрализованной сети хранения. Эта ссылка доступна для запроса с помощью функции tokenURI() , которая есть во всех ERC721, содержащих метаданные.

1*qfGcLNcw0FD_-IIZxVtujg.png


Для примера возьмем смарт-контракт Bored Ape Yacht Club . Вызов функции tokenURI() с идентификатором tokenId, равным 0, возвращает следующую ссылку: ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/0.

1661914512078.png


Если вы перейдете по этой ссылке, вы найдете файл JSON, содержащий метаданные BAYC #0:

1661914537727.png


Эти атрибуты легко проверить в OpenSea :

1661914568908.png



Следует также отметить, что некоторые структуры данных просто невозможно реализовать в EVM из-за стоимости хранения. Например, представление графа с использованием матрицы смежности было бы совершенно невозможным из-за его пространственной сложности O(V²).
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх