Поздравляем с Новым годом и желаем всем нам больших свершений в 2019 году! Aventus приветствует вас, вернувшихся из зимних каникул, с сообщением, посвященным ошибкам в Solidity, нашим собственным, Алексом Пинто.

Когда кто-то начинает кодировать смарт-контракты в Solidity, рано или поздно он сталкивается с очень неприятным препятствием. Ошибка «Стек слишком глубокий». В эту ловушку легко попасть, а когда это случается, часто бывает трудно найти выход. Честно говоря, основная причина не в самой Solidity, а в виртуальной машине Ethereum (EVM), и поэтому, вероятно, повлияет на другие языки, которые компилируются в EVM (например, LLL, Serpent, Viper), но это тонкое различие в повседневной работе по написанию смарт-контрактов.

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

В общем, эта ошибка возникает, когда коду требуется доступ к слоту в стеке, который глубже, чем его 16-й элемент (считая сверху вниз). Однако мы можем добиться этого несколькими способами. Этот пост не ставит своей целью предложить полную теорию того, как возникает эта ошибка: по моему опыту, существует слишком много способов сделать это. Но это даст хорошее обоснование для общего триггера и, надеюсь, заставит читателя лучше понять, как EVM управляет своим стеком. Возможно, даже удастся распространить ту же логику на другую ситуацию, в которой возникает ошибка, и искать способы ее избежать.

В Solidity большинство типов (например, элементарных типов, таких как числа, адреса и логические значения, но не массивы, структуры или сопоставления) передаются функции по значению: при вызове функции часть стека (например, стек frame) выделяется для удержания позиции возврата, в которую программа должна перейти при возврате функции («адрес возврата») и копии входных и выходных аргументов типа значения. Каждый аргумент обычно содержит слот в стеке, где каждый слот составляет 256 бит.

Это обеспечивает самый простой способ столкнуться с ошибкой «Stack Too Deep»: иметь в общей сложности более 16 входных и выходных аргументов. Но на самом деле, если мы хотим, чтобы эта функция делала что-то полезное, нам нужно быть очень осторожными и, вероятно, придется уменьшить количество аргументов.

Чтобы проверить это, я создал небольшой контракт в Remix, например:

pragma solidity ^0.4.24;
contract TestStackError {
  event LogValue(uint);
  function logArg(uint a1) public {
    emit LogValue(a1);
  }
}

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

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

Я копирую этот контракт в новый файл в Remix, компилирую и развертываю. Ошибок и предупреждений быть не должно, поэтому я перехожу на вкладку Выполнить и нажимаю Развернуть.

Затем я расширяю список для контракта SimpleFunction и ввожу одно значение в поле перед logArg. Нажимаю на кнопку и проверяю вывод в консоли:

Как видите, я ввел значение 7, и оно было возвращено как единственный элемент в журналах. Хотя журналы заслуживают отдельного поста, есть несколько вещей, которые я должен здесь упомянуть.

Это объект журнала этого вызова в формате JSON:

logs [
{
  "from": "0xef55bfac4228981e850936aaf042951f7b146e41",
  "topic": "0xfcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250",
  "event": "LogValue",
  "args": {
    "0": "7",
    "length": 1
  }
}
  • Журналы создаются с помощью ключевого слова emit в solidity, которое повышает надежность event и соответствует LOGn кодам операций.
  • Журналы могут быть отфильтрованы клиентскими приложениями, работающими вне сети. Фильтр - это условие для любой из тем, доступных в журнале.
  • В журнале всегда есть тема 0, которая представляет собой кодировку подписи события.
  • Дальнейшие темы могут быть созданы путем индексации аргумента. Может быть до 3-х индексированных аргументов. Остальные считаются данными события

В этом простом примере мы можем легко определить, что существует только одна тема ("0xfcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250") и что данные отображаются как часть args члена объекта журнала. Мы также можем убедиться, что код работает должным образом.

Давайте теперь проверим пределы этого контракта и изменим функцию, чтобы она принимала максимальное количество аргументов.

pragma solidity ^0.4.24;
contract TestStackError {
  event LogValue(uint);
  function logArg(uint a1, uint a2, uint a3, uint a4,
	uint a5, uint a6, uint a7, uint a8,
	uint a9, uint a10, uint a11, uint a12,
	uint a13, uint a14, uint a15, uint a16
  ) public {
    emit LogValue(a16);
  }
}

У меня 16 входных переменных, нет выходных переменных, поэтому мне нужно использовать только 16 слотов стека. Я вызываю функцию, передавая значения от 1 до 16, и выдаю последнее значение. Я проверяю журналы и вижу значение 16. Замечательно, это работает!

Затем я вношу очень небольшие изменения в свой контракт: вместо этого я регистрирую первый аргумент:

Чего ждать?! Простая регистрация другого аргумента превратила идеальный контракт в ошибку «Stack Too Deep». Вау, что здесь происходит?

Это не то, что Solidity может прояснить. На этом уровне изменение выглядит совершенно безобидным. Мне нужно погрузиться в байт-код EVM, чтобы понять, что происходит. Но прежде чем я это сделаю, я хочу провести еще один тест, чтобы собрать некоторые подсказки. Я создаю третью версию этого контракта, но вместо этого регистрирую a2:

pragma solidity ^0.4.24;
contract TestStackError {
  event LogValue(uint);
  function logArg(uint a1, uint a2, uint a3, uint a4,
	uint a5, uint a6, uint a7, uint a8,
	uint a9, uint a10, uint a11, uint a12,
	uint a13, uint a14, uint a15, uint a16
  ) public {
    emit LogValue(a2);
  }
}

Это работает и регистрирует правильное значение. То же самое происходит, когда я регистрирую a3. Тогда я предполагаю, что все аргументы между a2 и a16 могут быть правильно зарегистрированы.
Результирующие коды операций находятся в этих трех файлах:

журнал (a2)

журнал (a3) ​​

журнал (a16)

Я сравнил все 3 журнала между собой, и первое, что меня поразило, это то, что все они различались по размеру (количеству строк). Во-вторых, они заметно равны до строки 237, за одним исключением. Код после этой строки очень отличается и, по-видимому, непредсказуем. Однако, поскольку кажется, что это происходит после того, как функция вернулась, я просто проигнорирую это.

Затем я сосредоточился на одном различии между строкой 237, которое встречается в строке 198. Я был счастлив подтвердить идею, которая, как я думал, может объяснить слишком глубокую ошибку стека - что в каком-то месте кода нам логически нужно будет вызвать некоторую non -существующий код операции DUP или SWAP. Кажется, что это действительно так: все 3 версии одинаковы до строки 237, за исключением одного единственного различия в строке 198:

  • log(a2): DUP16
  • log(a3): DUP15
  • log(a16): DUP2

Коды операций DUPn дублируют значение на n-м уровне стека. Таких кодов операций всего 16, от DUP1 до DUP16. DUP1 помещает в стек копию текущего верхнего значения, а DUP16 копирует 16-е по величине значение в стеке. Существует очевидная взаимосвязь между местом переменной в списке аргументов и значением DUPn в этой строке, и если я экстраполирую его на журнал наблюдений (a1), это правило подразумевает, что нам понадобится код операции DUP17. Но такого кода операции не существует, он указывает на значение в стеке ниже, чем мы можем достичь, что оправдывает сообщение об ошибке «Stack Too Deep».

Удовлетворенный этим, мое естественное любопытство задает вопрос: какую роль здесь выполняет этот код операции DUP? Какова его цель?

Байт-код пугает. В последний раз я смотрел на ассемблерный код с некоторым уровнем намерения понять его, когда был подростком, играя с процессором Spectrum Z80. У меня нет опыта работы с EVM, поэтому я не планирую разбирать в голове 200 строк ассемблерного листинга. Но Remix действительно предлагает неплохие инструменты в этом отношении. На вкладке отладки мы можем воспроизвести код операции транзакции по коду операции и сразу увидеть содержимое стека, памяти и хранилища, среди прочего.

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

В этой функции нет ничего особенного, и большая часть байт-кода повторяется. Код операции CALLDATALOAD встречается 17 раз. Первый появляется в первом блоке кода перед отправкой функции. Он проверяет, не слишком ли коротка calldata (строка 12), и в этом случае функция вернется. После этого он сравнивает селектор функции с методами, известными контракту (в данном случае только один: e898288f), и, если он совпадает с каким-либо, направляет поток на адрес, реализующий эту функцию. В противном случае вызов возвращается.

В этом случае код вызвал единственную существующую функцию, и поэтому поток переходит к адресу 70 (строка 25) для ее обработки.

Остальные 16 экземпляров CALLDATALOAD - это точно такое же количество или аргументов, которые у нас есть, они появляются с интервалом ровно в 9 строк и, вероятно, отвечают за обработку каждого аргумента функции. Итак, я пробежался по этим строкам с помощью отладчика Remix и заметил, что они действительно загружают каждый последующий аргумент в стек (меня не беспокоит, как именно эти 9 кодов операций копируют эти данные). За ними следуют 3 POP инструкции, которые очищают часть стека, которая нам больше не нужна (которая использовалась для вычисления позиции в данных вызова следующего аргумента для чтения). В этот момент вершина стека содержит 16-й аргумент, второй элемент - 15-й аргумент и так далее. 16-й элемент стека на данном этапе является первым аргументом. За ним следует адрес возврата функции (0x109) и селектор функции.

Затем код помещает в стек 32-байтовый идентификатор темы 0 fcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250, который выталкивает первый ввод из верхних 16 элементов стека, и следует за ним с помощью кода операции DUP, который помещает наверху стека аргумент для событие журнала (например, a2 или a16).

Следующие 20 строк или около того подготавливают память для хранения аргумента события журнала в позиции памяти 0x80 и гарантируют, что стек имеет в своих двух верхних позициях этот адрес и длину данных (0x20). Затем он вызывает код операции LOG1, который генерирует событие журнала с одним аргументом и одной темой, используя данные в трех верхних позициях в стеке:

  • 0: 0x0000000000000000000000000000000000000000000000000000000000000080
  • 1: 0x0000000000000000000000000000000000000000000000000000000000000020
  • 2: 0xfcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250

Всего существует пять кодов операций LOGn, от LOG0 до LOG4, где n указывает количество тем в журнале. Topic0 всегда является идентификатором типа события, определяемого хешем его подписи, но его можно пропустить, используя LOG0, который указывает анонимное событие. Каждая дополнительная тема требует еще одного слота в стеке, выталкивая еще столько аргументов из списка доступных.

Этот анализ показывает, что событие с одним аргументом предотвращает использование одной переменной, потому что topic0 помещается в стек перед данными события. Возникает пара вопросов:

  • Что, если у нас будет больше тем? Они также помещаются в стек перед данными?
  • И каково влияние большего количества аргументов события, они ПУШЕНЫ после или до темы?

Чтобы проверить это, я снова изменю контракт. Обратите внимание, что события могут иметь любое количество аргументов, и до 3 из них могут быть проиндексированы. Проиндексированные аргументы становятся темами, а остальные сосредоточены в разделе данных. Моя гипотеза на данном этапе заключается в том, что каждая тема (индексированный аргумент) будет помещена в стек перед данными, что предотвратит доступ к большему количеству ранних переменных.

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

Во-первых, давайте попробуем эту версию контракта, в которой событие имеет одно индексированное значение и два неиндексированных.

pragma solidity ^0.4.24;
contract TestStackError {
  event LogValue(uint indexed a1, uint a2, uint a3);
  function logArg(uint a1, uint a2, uint a3, uint a4,
	uint a5, uint a6, uint a7, uint a8,
	uint a9, uint a10, uint a11, uint a12,
	uint a13, uint a14, uint a15, uint a16
  ) public {
    emit LogValue(a2, a3, a4);
  }
}

Байт-код для этой функции (после отправки функции) до тех пор, пока событие не будет сгенерировано, следующий:

265 JUMPDEST
266 DUP15
267 PUSH32 a5397a5faa0ec7cfb89428503b91a13bbd737592f7561e6773fa3e1458c8735c
300 DUP16
301 DUP16
302 PUSH1 40
304 MLOAD
305 DUP1
306 DUP4
307 DUP2
308 MSTORE
309 PUSH1 20
311 ADD
312 DUP3
313 DUP2
314 MSTORE
315 PUSH1 20
317 ADD
318 SWAP3
319 POP
320 POP
321 POP
322 PUSH1 40
324 MLOAD
325 DUP1
326 SWAP2
327 SUB
328 SWAP1
329 LOG2

Код операции, который генерирует событие, - LOG2. Это означает, что у нас есть две темы, одна - это тема0 по умолчанию (то есть подпись события), а другая - единственный индексированный аргумент в подписи события. Остальные два значения сгруппированы в памяти.
Если мы проверим Ethervm для этого кода операции, мы увидим, что последнее значение считывается из стека, а первое значение, которое будет помещено в it, это topic1, то есть индексированный аргумент - a2. Первоначально он помещается в позицию 15 стека. Код операции DUP15 помещает копию значения в верхнюю часть стека и, следовательно, сдвигает все остальные аргументы вниз. С этого момента, например, a2 находится в позиции 16, а a1 - в позиции 17.

Следующая инструкция помещает в стек 32-битное значение, которое просто соответствует теме 0. Это значение жестко запрограммировано. Это также приводит к тому, что аргументы снова опускаются. Теперь a2 находится на позиции 17.

Следующие инструкции представляют собой два DUP16 кода операций. Первый копирует значение в позицию 16, которая в настоящее время является третьим аргументом, a3. Но поскольку это помещает новый элемент в стек, при вызове следующего кода операции DUP16 будет копировать четвертый аргумент функции, a4. На этом этапе в верхней части стека у нас есть данные для события (два слова), индексированный аргумент и уникальный идентификатор события.

Следующие строки копируют в память первые два значения:

  • (302–305): дважды помещает содержимое памяти 0x40 наверх стека. Это позиция в памяти, где будут располагаться данные события (в моем исполнении это 0x80).
  • (306–308): помещает первое слово данных в первую свободную позицию в памяти (т. Е. Помещает a3 в позицию 0x80
    )
  • (309–311): помещает следующую свободную позицию в памяти вверху стека.
  • (312–314): помещает второе слово данных в следующую свободную позицию в памяти (т. Е. Размещает a4в позиции 0xa0
    )
  • (315–321): вычисляет следующую свободную позицию в памяти и оставляет ее наверху стека после удаления значений, которые больше не нужны.
  • (322–327): находит длину данных, представленных событию, путем вычитания начального адреса следующей свободной позиции в памяти из текущего значения этой позиции (удерживаемой наверху стека).
  • (328): переупорядочивает первые два элемента стека, делая первый элемент началом данных события, а второй - длиной этих данных.
  • (329): наконец вызывает код операции регистрации.

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

event LogValue(uint a1, uint indexed a2, uint a3);

Да, еще одна ошибка Stack Too Deep. Вы видите, что его вызывает?

……

………

Байт-код не сильно меняется. У нас по-прежнему то же количество тем, поэтому код операции в конце по-прежнему будет LOG2. И он по-прежнему ожидает получить свои аргументы в том же порядке, то есть сначала темы, а затем данные.

Теперь вторая тема должна быть загружена первой, поэтому a3 будет первым значением, которое будет помещено в стек с DUP14. Тогда будет выталкиваться topic0. Теперь EVM поместит наверху стека два аргумента, которые необходимо сохранить в памяти, a2 и a4. Первоначально они были в позициях 15 и 13. Однако EVM уже сделал два нажатия, что сделало эти позиции 17 и 15. Невозможно поместить первое значение в стек (DUP17 не существует) и, следовательно, ошибки компиляции.

Итак, теперь, когда мы это понимаем, я пытаюсь изменить еще одну вещь, функцию журнала, на:

emit LogValue(a3, a2, a4);

Этот код работает, поскольку он очень близко соответствует последнему блоку до того, как я изменил порядок проиндексированных аргументов. В этом коде индексированное значение события вызывалось с a2. В этой версии этой позиции по-прежнему передается a2, а остальные остаются прежними. Объяснение байт-кода практически такое же.

Заключение

Это был длинный пост. Если вы зашли так далеко, стоит оставить вас с организованным взглядом на то, что происходит, чтобы вы могли вернуться к своим программам и подумать, не могли ли ваши ошибки «Stack Too Deep» быть вызваны аналогичным поведением. Хотя этот пост охватывает только случай генерации событий, другие функции будут использовать другие коды операций, но по-прежнему будут иметь ту же логику при копировании аргументов функции (или промежуточных значений) в стек, когда требуются некоторые вычисления.

Итак, вот несколько упрощенных примечаний, о которых следует помнить:

  • При вызове функции создается кадр стека. Сюда входят, снизу вверх:
  • селектор функций
  • обратный адрес
  • крайний левый аргумент типа значения функции
  • крайний правый аргумент типа значения функции
  • Ошибки «Stack Too Deep» зависят от центрального кода операции действия (например, арифметики, хеширования, вызова другой функции, выдачи событий и т. Д.)
  • Если эти центральные операции выполняются с чистыми аргументами функции, порядок, в котором они передаются функции, может определять возникновение ошибки «Stack Too Deep». (Слоты стека также могут использоваться для промежуточных вычислений и локальных переменных, но я намерен изучить их в более позднем посте.)
  • Очень важно знать количество и порядок аргументов для кода операции. Эти аргументы обычно считываются из стека (единственное исключение - код операции PUSH).
  • Аргументы кода операции должны быть помещены в стек перед выполнением кода операции. Каждый PUSH перемещает аргументы функции вниз, по крайней мере, на один слот. Аргументы функции глубже в стеке - это те, которые были обработаны первыми, то есть самые левые аргументы в сигнатуре функции.
  • Если некоторые из аргументов функции не используются в этой операции кода операции, тогда они должны быть первыми в сигнатуре функции, чтобы уменьшить вероятность того, что аргументы кода операции будут недоступны, когда их нужно будет сложить в стек.
  • Коды операций используют аргументы на разных уровнях стека. Сначала продвигаются более глубокие уровни. Если аргумент помещается после другого, он также должен появиться в сигнатуре функции после первого, иначе он вытолкнет другой аргумент вниз по стеку, прежде чем его можно будет использовать. Пример:
  1. Рассмотрим событие с двумя проиндексированными аргументами t1 и t2 в этом порядке, которое вызывается внутри функции с несколькими аргументами, среди которых a1 предшествует a2
  2. Если событие отправлено с t1 = a1 и t2 = a2, будет вызван код операции LOG3.
  3. Перед вызовом этого кода операции t2 = a2 будет помещен в стек первым.
  4. Это приведет к понижению a1 и риску стать недоступным, когда придет время подтолкнуть значение t1 = a1.
  5. Этого можно было бы избежать, если бы a1 был указан после a2 в сигнатуре функции, поскольку он был бы выше в стеке, чем a2. Если предположить, что a2 был доступен, когда он был отправлен, то будет a1 после этого.
  • Вышеупомянутое сообщение было сосредоточено только на LOGn кодах операций, в частности на версиях, требующих 3 или 4 аргумента в стеке. Более сложным случаем будет вызов функций из других контрактов или библиотек, поскольку коды операций CALL и DELEGATECALL принимают 7 или 6 входных аргументов каждый, с гораздо большим количеством возможностей взаимодействия между кодом операции и аргументами функции.

Я надеюсь, что это даст вам некоторые подсказки о том, как отлаживать и обрабатывать ошибки «Stack Too Deep». Есть еще много чего сказать, но придется подождать, пока появятся другие возможности.

До скорого.

Алекс - инженер-программист в Artos, нашем партнере по экосистеме, работает в команде разработчиков блокчейнов. Он имеет 20-летний опыт работы в сфере технологий, защитил докторскую диссертацию в области компьютерных наук, а также защитил докторскую диссертацию в области криптографии. В рамках своего исследования Алекс опубликовал статьи о сложности Колмогорова, криптографии, анонимизации баз данных и обфускации кода.

Пинто также семь лет читал лекции в Университетском институте Майи, в том числе руководил программами бакалавриата по информатике, информационным системам и программному обеспечению.

Эта статья изначально была размещена в его блоге.

Поскольку вы здесь, мы были бы рады, если бы вы связались с нами в Telegram, Reddit, Twitter, Facebook, Youtube, Instagram и LinkedIn. ».

Кроме того, мы создали группу LinkedIn для разработчиков билетов / других разработчиков, чтобы присоединиться к нам и начать беседу.

Получайте лучшие предложения по программному обеспечению прямо в свой почтовый ящик