Solidity. Смарт контракты и аудит – Telegram
Solidity. Смарт контракты и аудит
2.62K subscribers
246 photos
7 videos
18 files
547 links
Обучение Solidity. Уроки, аудит, разбор кода и популярных сервисов
Download Telegram
Solidity hints. Часть 8

Сегодняшний пункт также немного нов для меня, поэтому если у вас будут уточнения по информации, пишите комментарии с вашим мнением.

12. Always send 1 wei to precompiled contracts to activate them when testing in private blockchains, otherwise it may lead to OOG

Всегда отправляйте 1 wei на precompiled контракты в приватных сетях, чтобы активировать их и избежать ошибки OOG.

Для начала нужно разобраться, что такое precompiled контракты и OOG.

OOG - out of gas - ошибка, которая возникает в транзакции, когда расходуется больше газа, чем было разрешено.

Далее чуть сложнее, обратившись в yellow pages сети мы можем узнать, что

Precompiled контракты - это контракты, предназначенные для предварительной разработки архитектуры сети, которая впоследствии может стать родным расширением. Четыре контракта в адресах 1, 2, 3 и 4 выполняют функцию восстановления открытого ключа по эллиптической кривой, 256-битную хэш-схему SHA2, 160-битную хэш-схему RIPEMD и функцию идентификации соответственно.

Как я понял, на данный момент всего 9 таких контрактов:

1. Recovery of ECDSA signature
2. Hash function SHA256
3. Hash function RIPEMD160
4. Identity
5. Modular exponentiation (EIP 198)
6. Addition on elliptic curve alt_bn128 (EIP 196)
7. Scalar multiplication on elliptic curve alt_bn128 (EIP 196)
8. Checking a pairing equation on curve alt_bn128 (EIP 197)
9. BLAKE2b hash function (EIP 152)

А в этой статье можно прочитать больше о первых 4 контрактах:

https://medium.com/@rbkhmrcr/precompiles-solidity-e5d29bd428c4

А тут обо всех 9:

https://www.rareskills.io/post/solidity-precompiles

Далее по пункту, что мы разбираем, можно найти упоминание в официальной документации Solidity:

При выполнении функций sha256, ripemd160 или ecrecover на приватном блокчейне вы можете столкнуться с проблемой Out-of-Gas. Это происходит потому, что эти функции реализованы как "прекомпилированные контракты" и реально существуют только после получения первого сообщения (хотя код их контрактов жестко закодирован). Сообщения несуществующим контрактам стоят дороже, и поэтому при выполнении может возникнуть ошибка Out-of-Gas. Обходной путь решения этой проблемы - сначала отправить Wei (например, 1) каждому из контрактов, прежде чем использовать их в своих реальных контрактах.

Как я понял, precompiled контракты - это не совсем обычные контракты, которые мы с вами пишем на Solidity. Это специальные контракты, которые являются частью самой сети, и используются для каких-либо конкретных действий. Например, для восстановления подписи через ecrecover!

За весь период своего обучения всего пару раз сталкивался с этой темой и сегодня было достаточно интересно "покопаться в доках" и узнать немного нового.

#precompile
👍51
Solidity hints. Часть 9

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

13. Functions called from within an unchecked block do not inherit the property. Bitwise operators do not perform overflow or underflow checks

Функции, вызываемые из unchecked блока, не наследуют это свойство. Побитовые операторы не выполняют проверку на переполнение или недополнение.

Unchecked блок по своей задаче не выполняет проверки на overflow, поэтому если мы в функции:

    function g(uint a, uint b) pure public returns (uint) {
return a - b;
}


вычтем 3-4, то вернется огромное число приближенное к максимальному значению uint256.

Однако... Посмотрите на этот вариант:

contract Test {
function unsafe_subtract(uint a, uint b) pure public returns (uint) {
unchecked {
return subtract(a,b);
}
}

function subtract(uint a, uint b) pure public returns (uint) {
return a - b;
}
}


в этом случае, несмотря на то, что исполнение функции subtract() происходит в unchecked блоке, проверка на переполнение будет все равно выполнена. Другими словами функция в блоке не будет наследовать от unchecked игнор проверки overflow.

Также, если мы используем побитовые операции или исполнение функции в assembly блоках, то в них НЕ будет проверки на переполнение.

#overflow
🔥3
Solidity hints. Часть 10

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

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

А на канале мы продолжаем разбирать пункты из репо Chinmaya, и сегодня у нас по счету уже 14:

14. After a failed call, Do not assume that the error message is coming directly from the called contract: The error might have happened deeper down in the call chain and the called contract just forwarded it (bubbling up of errors)

что в переводе:

После неудачного вызова не думайте, что сообщение об ошибке пришло непосредственно от вызванного контракта: ошибка могла произойти глубже в цепочке вызовов, а вызванный контракт просто переслал ее.

В простых протоколах обычно вызовы могут идти в "соседний" контракт, например:

Контракт А => Контракт В

В более сложных протоколах, каких-нибудь DeFi, эти вызовы могут проходить через несколько контрактов, или даже протоколов:

А => В => С => А => Е

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

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

15. Calling a function on a different contract (instance) will perform an EVM function call and thus switch the context such that state variables in the calling contract are inaccessible during that call (except if you use delegatecall)

и перевод:

Вызов функции на другом контракте выполнит вызов функции EVM и, таким образом, переключит контекст так, что переменные состояния в вызывающем контракте будут недоступны во время этого вызова (не считая использования delegatecall)

Тут также все достаточно просто. Например, если мы делаем вызов из контракта А в контракт В, то переменные контракта А будут недоступны для изменения в контракте В, так как вызов "переключится" контекстом на В.

Еще проще, допустим у нас есть переменная owner в А. Мы делаем вызов из А в В и там вызываем функцию для изменения адреса владельца.

И несмотря на то, что вызов идет через А, переменная будет изменения в В.

При этом, если мы используем функция с delegatecall в А, делая вызов в В, то мы "как бы захватываем" функцию из В, переносим ее в А и изменяем уже состояние в памяти А.

Звучит немного запутано, но стоит разобраться с call() и delegatecall(), как все встанет на свои места!

#solidity #call #delegatecall
👍3
Solidity hints. Часть 11

Фух, лето... В моем городе на солнце уже +42, работать даже под кондиционером достаточно сложно. Но мы все равно понемногу повторяем азы Solidity, и переходим к следующему пункту:

16. After contract creation, The deployed code does not include the constructor code or internal functions only called from the constructor

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

Как вы можете уже знать, что при деплое контракта его код разделяется на две части: init code и runtime code. Например, возьмем самый простой контракт:

pragma solidity 0.8.17;// optimizer: 200 runscontract Minimal {
constructor() payable {

}
}


Когда мы выполним деплой контракта, его байткод будет следующим:

0x6080604052603f8060116000396000f3fe6080604052600080fdfea2646970667358221220d03248cf82928931c158551724bebac67e407e6f3f324f930c4cf1c36e16328764736f6c63430008110033

И можно разделить его на:

init code - 0x6080604052603f8060116000396000f3fe

runtime code - 6080604052600080fdfea2646970667358221220d03248cf82928931c158551724bebac67e407e6f3f324f930c4cf1c36e16328764736f6c63430008110033

P.S. Весь байткод, по сути, это перечисление опкодов и значений для него, но это слишком сложная тема для обсуждения в данном посте.

Вот этот вот runtime code появляется после выполнения init code и является уже нашим смарт контрактом, в котором мы можем вызывать различные функции.

В обсуждаемом пункте говорится о том, что действия контракта в конструкторе не включаются в итоговый код runtime, а используются исключительно в init code, в моменте создания самого контракта.

Более того, если в нашем контракте присутствуют internal функции, которые используются только в конструкторе, то они также не будут включены в итоговый runtime код!

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

Ethereum smart contract creation code

#creationcode #runtime #init
4👍4
Solidity hints. Часть 12

Сегодня чуть припозднился с постом. Иногда в репо встречаются какие-то обрывки из правил, и я трачу время, чтобы понять:

1) О чем вообще идет речь?
2) Откуда это он взял?

И вот сейчас пункт звучит:

17. Dont use this.f inside constructor

Кто это вообще использует? Я за все время ни разу не встречал в конструкторах this, а знаете почему?

Потому что его вообще нельзя использовать там!

Я к тому что, зачем писать пункт к себе в репо, который и так будет помечаться компилятором, как ошибка, и он не позволит вам собрать контракт...

Ну, в общем, рассказываю, что-о-чем тут.

Он читал официальные доки Solidity и встретил комментарий к коду:

State variables are accessed via their name and not via e.g. `this.owner`. Functions can be accessed directly or through `this.f`, but the latter provides an external view to the function. Especially in the constructor, you should not access functions externally, because the function does not exist yet.

Немного запутано написано, но я понял, что this, в этом случае, как бы выполняет вызов в функцию из вне создаваемого контракта, и так как последняя еще не существует, он выдает об этом ошибку.

При этом, если использовать туже функцию, но без this, то все работает нормально. Например:

contract TestThis {

uint256 private num;

// не работает
constructor() {
num = this.increment();
}

function increment() internal view returns(uint256){
return num + 5;
}
}

contract TestThis {

uint256 private num;

// работает
constructor() {
num = increment();
}

function increment() internal view returns(uint256){
return num + 5;
}
}


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

Ну, и еще один пункт напоследок:

18. Internal is the default visibility level for state variables.

Тут все просто, у каждого типа данных в Solidity есть некое значение по-умолчанию. У uint - это 0, у bool - false и т.д.

И у каждой переменной состояния есть область видимости: public, external, internal и private. Если мы не указываем нужную нам область, то по умолчанию устанавливается internal, что запрещает обращаться к ней из других внешних контрактов.

#constructor #visibility
👍2
Solidity hints. Часть 13

Понемногу движемся дальше и сегодня на очереди у нас:

19. Internal function calls do not create an EVM message call. They are called using simple jump statements. Same for functions of inherited contracts.

что в переводе:

Internal функции не создают EVM вызов, а используют внутренние опкоды jump. Тоже самое актуально и для наследуемых контрактов.

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

У нас есть 4 области видимости для функций: public, external, internal и private. Первые две - открытые для доступа других контрактов, вторые - закрыты.

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

Доступ к внутренним функциям возможен только внутри текущего контракта или контрактов, вытекающих из него. Они не могут быть доступны извне. Поскольку они не открыты для внешнего доступа через ABI контракта, они могут принимать параметры внутренних типов, таких как mapping или storage references.

Внутренние функции вызываются через инструкции опкоды JUMP / JUMPI, просто перепрыгивая в другую точку текущего кода. Это имеет смысл, потому что внутренние функции не меняют контекст (то есть остаются внутри одного и того же контракта при вызове).

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

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

Следующий пункт:

20. If you have a public state variable of array type, then you can only retrieve single elements of the array via the generated getter function.

что в переводе и полной версии из документации звучит как:

Если у вас есть публичная переменная состояния типа массив, то вы можете получить только отдельные элементы массива через сгенерированную функцию getter. Этот механизм существует для того, чтобы избежать высоких газовых затрат при возврате целого массива. Вы можете использовать аргументы, чтобы указать, какой отдельный элемент возвращать, например myArray(0). Если вы хотите вернуть весь массив за один вызов, то вам нужно написать функцию, например:

contract arrayExample {

uint[] public myArray;
function myArray(uint i) public view returns (uint) {
return myArray[i];
}

function getArray() public view returns (uint[] memory) {
return myArray;
}
}


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

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

Для первой переменной у вас появится кнопка в левой панели Ремикса, для того чтобы посмотреть ее значение, но для internal - ее не будет.

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

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

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

#array #getter #jump #external
👍2💯2🌭1
Solidity hints. Часть 14

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

А мы продолжаем изучать нюансы Solidity и сегодня на очереди следующие пункты из репо Chinmaya:

21. Without a payable keyword in function declaration, it will auto reject all ether sent to it. It will revert.

что в переводе:

Без ключевого модификатора payable в функции, она не сможет принимать Эфир и будет откатывать транзакцию.

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

Вы можете сами зайти на сайт с подбором багов из контестов - Solodit - и ввести в поиск missing payable. На данный момент там 67 багов по этой теме...

Чаще всего разработчики забывают установить этот модификатор во внешних вызовах между контрактами. Например, из одного контракта мы отправляем Эфир в другой, там в функции делаем расчеты с msg.value, но забываем payable.

Тут единственное, что можно порекомендовать проверять наличие payable в функциях, где есть обработка msg.value, а также отслеживать движение Эфира и токенов между контрактами. Так мы сможете обнаружить и другие возможные баги.

22. Modifiers can also be defined in libraries but their use is limited to functions of the same library

что в переводе:

Модификаторы также можно создавать и в библиотеках, но они могут быть использованы только для функций в этих библиотеках.

Вообще библиотеки такая странная штука в Solidity, что она порой вызывает недоумение и у опытных разработчиков.

Библиотеки не могут хранить Эфир, не имеют своего storage, а то как они работают с вашим контрактом при наличии internal / external функций и говорить не стоит...

А тут еще и модификаторы подоспели.

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

#modifier #payable #library
👍3
Solidity hints. Часть 15

Мы уже разобрали более 20 пунктов из репо и я подумываю сделать недельный перерыв от них. В том плане, что хочется изучить или рассмотреть что-то новое, и вот смотрю я на Account Abstraction. Может разберем его на следующей неделе?

Было несколько годных статей на Alchemy по этой теме, да и Патрик Коллинс выпустил 4 часовое видео. Для первого касания хватит, а там посмотрим чего нам не будет хватать для понимания темы.

Ну, а пока что, еще пара пунктов:

23. Multiple modifiers are applied to a function by specifying them in a whitespace-separated list and are evaluated in the order presented. Modifier Order Matters

что в переводе:

Несколько модификаторов применяются к функции путем указания их в списке, разделенном пробелами, и оцениваются в указанном порядке. Порядок модификаторов имеет значение.

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

contract MyToken is ERC20, ERC20Permit, Ownable {
}


и если мы нарушим этот порядок и поставим ERC20Permit перед основным контрактом ERC20, то сам компилятор будет выдавать ошибку.

С модификаторами чуточку сложнее. В большинстве случаев компилятор на покажет вам предупреждений или ошибок по вопросу их порядка в функции. Однако сама логика работы функции может быть нарушена.

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

pragma solidity ^0.4.18;

contract modifierTest {
uint public modState1;
uint public modState2;
uint public modState3;

modifier modA() {
modState1 = modState1 + 1;
_;
}

modifier modB() {
modState2 = modState2 + 1;
_;
modState2 = modState2 + 1;
_;
}

function func() public modA modB {
modState3 = modState3 + 1;
}
}


Как думаете, как значения получатся после выполнения функции? И ответ на этот вопрос моно будет найти в следующем пункте:

24. The _ symbol can appear in the modifier multiple times. Each occurrence is replaced with the function body. Symbols introduced in the modifier are not visible in the function

что в переводе:

Символ _ может встречаться в модификаторе несколько раз. Каждое появление заменяется телом функции. Символы, введенные в модификатор, не видны в функции.

Думаю, тут будет лучше самим перенести код в Ремикс и попробовать "поиграться" с функцией, так будет намного понятнее, как происходит работа внутри и на что влияет порядок.

#modifier
🔥3👍2
Solidity hints. Часть 16

Последний пост перед небольшим перерывом с циклом Solidity Hints, так как на следующей неделе мы поговорим про Account Abstractions.

Итак:

25. For values of immutable variables, 32 bytes are reserved in the code, even if they would fit in fewer bytes
26. The compiler does not reserve a storage slot for constant and immutable variables, and every occurrence is replaced by the respective value directly in the code.


что в переводе:

25. Для значений immutable переменных всегда резервируется 32 байта памяти, не смотря на то, что сами значения могут по итогу заниматься меньше места.
26. Компилятор не резервирует слот для хранения постоянных и неизменяемых переменных, и каждое их вхождение заменяется соответствующим значением непосредственно в коде.

Я решил объединить эти пункты, так как разбор описывает оба этих случая.

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

uint256 - занимает весь слот памяти в 32 байта;

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

address занимает 20 байтов, а bool - всего 1.

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

При этом два вида переменных, constant и immutable - размещаются не в памяти контракта, а в его байткоде.

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

#immutable #constant #storage #bytecode
👍4
Account abstraction (ERC-4337). Часть 1

С этого дня начнем разбирать AA и для начала я решил прочитать серию статей с Alchemy, перевод чего и буду делать тут. Если у вас есть еще какие интересные материалы по этой теме, делитесь в комментариях.

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

Для начала давайте придумаем, как защитить наши самые ценные активы. Мы хотим иметь возможность подписывать большинство транзакций одним закрытым ключом (как в обычном аккаунте кошельке), и наш бесценный Courage NFT можно будет передать, только если мы подпишем его вторым ключом, который мы закроем в банковском хранилище, охраняемом трехголовым псом.

И вот какой будет наша первая задача.

Каждый счет Ethereum - это либо смарт-контракт, либо счет, принадлежащий внешнему владельцу (EOA), причем последний управляется off-chain с помощью закрытого ключа. Должен ли счет, на котором хранятся эти активы, быть смарт-контрактом или EOA?

На самом деле, держателем активов должен быть смарт-контракт. Если бы это был EOA, то активы всегда можно было бы передать с помощью транзакций, подписанных закрытым ключом EOA, что не обеспечит нужную нам безопасность.

Поэтому, в отличие от большинства людей, наше присутствие onchain будет представлено смарт-контрактом, а не EOA, который мы будем называть кошельком смарт-контракта или просто «кошельком».

Нам нужен способ отдавать команды этому смарт-контракту, чтобы он выполнял нужные нам действия. В частности, нам нужно иметь возможность приказать смарт-контракту выполнить любой вид call()/transfer(), который я мог бы отправить из EOA.

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

Например, если кто-то захочет отправить NFT кому-то в контракте с объединенным кошельком, API передачи NFT позволит отправителю указать только адрес объединенного кошелька, но не отдельного пользователя в нем.


Пользовательские операции

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

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

Итак, контракт кошелька выглядит следующим образом:

contract Wallet {
function executeOp(UserOperation op);
}


‍Что входит в пользовательскую операцию?

Во-первых, нам нужны все параметры, которые мы обычно передаем в eth_sendTransaction:

struct UserOperation {
address to;
bytes data;
uint256 value; // Amount of wei sent
uint256 gas;
// ...
}


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

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

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

Мы также добавим nonce для предотвращения атак повторного воспроизведения, когда кто-то может повторно отправить предыдущую пользовательскую операцию, чтобы запустить ее снова:

struct UserOperation {
// ...
bytes signature;
uint256 nonce;
}


Это то, что нам нужно! Пока мой Carbonated Courage NFT находится под действием этого контракта, он не может быть передан без двух подписей.
2
P.S. Хотя кошелек может интерпретировать поля подписи и nonce по своему усмотрению, я ожидаю, что почти все кошельки будут использовать поле подписи для получения некой подписи поверх всех остальных полей, чтобы предотвратить подделку или фальсификацию операции неавторизованными лицами. Аналогично, я ожидаю, что почти любой кошелек будет отклонять операции с уже виденным им nonce.

Далее поговорим о том, что может вызывать наш кошелек - смарт контракт.

#accountabstraction
👍4
Account abstraction (ERC-4337). Часть 2

Кто вызывает кошелек смарт-контракта?

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

В Ethereum все транзакции должны исходить от EOA, а вызывающий EOA должен оплачивать газ своими собственными ETH.

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

Таким образом, мы фактически получили большую часть функциональности а account abstraction с помощью всего одного довольно простого контракта!

#accountabstraction
Account abstraction (ERC-4337). Часть 3

Цель: отсутствие отдельного EOA

Недостатком вышеописанного решения является требование завести отдельный аккаунт EOA для вызова кошелька. Что, если я не хочу этого делать? На данный момент я по-прежнему готов платить за свой газ с помощью ETH. Я просто не хочу иметь два отдельных счета.

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

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

P.S. «Исполнитель» - это не термин ERC-4337, но это хорошее описание того, что делает этот участник. Позже мы заменим его на реальный термин, используемый в ERC-4337, «bundler», но пока нет смысла делать это, поскольку в данный момент мы не занимаемся никакими связками. В других протоколах этот участник может также называться «ретранслятором».


#accountabstraction
👍3
Account abstraction (ERC-4337). Часть 4

Первая попытка: Кошелек возвращает деньги исполнителю в конце

Давайте попробуем упростить задачу. Мы сказали, что интерфейс кошелька:

contract Wallet {
function executeOp(UserOperation op);
}


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

Первое знакомство с симуляцией

Данная реализация отлично сработает, если мой кошелек заслуживает доверия у исполнителя. Но второй должен быть уверен, что кошелек действительно выплатит возмещение. Если исполнитель вызовет executeOp, а кошелек не вернет деньги за использованный газ, то исполнитель окажется обманут.

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

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

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

Симуляция может отличаться от реального выполнения по нескольким причинам:

1. Операция может считываться из хранилища (storage), а хранилище может меняться между временем симуляции и временем реального исполнения функции.

2. Операция может использовать такие опкоды, как TIMESTAMP, BLOCKHASH, BASEFEE и т. д. Эти опкоды считывают информацию из среды и непредсказуемо меняются от блока к блоку.

Исполнитель мог бы попытаться ограничить то, что разрешено делать при исполнении функции, например, отклонить любую операцию, использующую любой из опкодов «окружения». Но это было бы слишком жестким ограничением.

Помните, что мы хотим, чтобы кошелек мог делать все, что может делать EOA, поэтому запрет этих опкодов заблокирует слишком много законных применений. Например, это не позволит кошельку взаимодействовать с Uniswap, который широко использует TIMESTAMP.

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

#accountabstraction
🔥2
Account abstraction (ERC-4337). Часть 5

Попытка 2: Внедрение точки входа

Проблема здесь в том, что мы просим исполнителя запустить код из контракта, которому, возможно, нет доверия. Исполнитель хочет выполнять эти "недоверенные" операции в контексте, который предоставляет определенные гарантии. Чувствуете тонкую грань?

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

contract EntryPoint {
function handleOp(UserOperation op);


handleOp будет делать следующее:

1. Проверять, достаточно ли у кошелька средств для оплаты максимального количества газа, которое он может использовать (на основе поля gas в пользовательской операции). Если нет, то отказ.

2. Вызов метода executeOp кошелька (с соответствующим газом), отслеживая, сколько газа он фактически использует.

3. Отправка части ETH из кошелька исполнителю, как оплата за использованный газ.

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

contract EntryPoint {
// ...


function deposit(address wallet) payable;
function withdrawTo(address payable destination);
}


При такой реализации исполнитель получает компенсацию за газ, несмотря ни на что.

Этот способ отлично работает для исполнителя, но создает некоторые проблемы для кошелька...

#accountabstraction
Account abstraction (ERC-4337). Часть 6

Разделение проверки и исполнения функции

Ранее мы определили интерфейс кошелька как:

contract Wallet {
function executeOp(UserOperation op);
}


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

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

Если проверка не проходит, это означает, что кто-то, не имеющий полномочий над кошельком, попросил кошелек сделать что-то.

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

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

С другой стороны, если проверка прошла успешно, но операция после этого не удалась, то кошельку следует заплатить за использованный газ. Это означает, что владелец кошелька санкционировал действие, которое оказалось неудачным, например отправку отмененной транзакции из EOA, и поскольку он санкционировал это действие, он должен нести ответственность за это.

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

Дополним функции в нашем кошельке:

contract Wallet {
function validateOp(UserOperation op);
function executeOp(UserOperation op);
}


Новая реализация handleOp будет выглядеть так:

1. Вызов validateOp. Если это не удастся, то исполнение прекратится.

2. Отложить ETH из депозита кошелька, чтобы оплатить максимальное количество газа, которое он может использовать (на основе переменной газа в ОП). Если у кошелька недостаточно средств, отказать в исполнении.

3. Вызвать executeOp и отследить, сколько газа он использует. Независимо от того, удался этот вызов или нет, возместите исполнителю стоимость газа из средств, которые мы отложили, и верните оставшиеся средства на депозит кошелька.

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

Однако... дела у исполнителя снова идут не так как надо...

P.S. Мы должны быть уверены, что неавторизованный пользователь не сможет напрямую вызвать executeOp на кошельке, заставив его выполнить действие без проверки. Кошелек может предотвратить это, обеспечив, чтобы executeOp мог быть вызван только точкой входа.

#accountabstraction
Account abstraction (ERC-4337). Часть 7

Повторное моделирование

Теперь, когда неавторизованные пользователи отправляют операции для кошелька, операция проваливается в validateOp, и контракту не нужно платить за это. Однако исполнитель по-прежнему платит за работу validateOp и не получает за это компенсации.

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

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

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

Но в этот раз будет по-другому.

Исполнителю не нужно симулировать все выполнение, которое теперь состоит из validateOp и executeOp. Ему нужно симулировать только первую часть, validateOp, чтобы знать, получит он деньги или нет. И в отличие от executeOp, который должен иметь возможность выполнять произвольные действия, чтобы кошелек мог свободно взаимодействовать с блокчейном, на validateOp мы можем наложить более строгие ограничения.

Если быть точным, исполнитель отклонит пользовательскую операцию, не передавая ее в цепочку, если validateOp не удовлетворяет следующим ограничениям:

1. Он никогда не использует опкоды из определенного банлиста, в который входят такие коды, как TIMESTAMP, BLOCKHASH и т. д.

2. Единственное хранилище, к которому он обращается, - это связанное хранилище кошелька, определяемое как любое из следующих:

- Собственное хранилище кошелька.

- Хранилище другого контракта в слоте, соответствующем кошельку в связке(адрес => значение).

- Хранилище другого контракта в слоте, равном адресу кошелька (это необычная схема хранения, которая не встречается в Solidity).

Цель этих правил - свести к минимуму случаи, когда validateOp успешно работает в симуляции, но терпит неудачу при реальном выполнении.

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

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

С такой симуляцией и кошелек, и исполнитель находятся в безопасности.

P.S. Это ограничение хранилища имеет еще одно преимущество: мы знаем, что вызовы validateOp для операций с разными кошельками вряд ли будут мешать друг другу, поскольку хранилище, к которому они могут обращаться, ограничено. Это будет важно, когда мы будем говорить о пакетировании.

#accountabstraction
Account abstraction (ERC-4337). Часть 8

Оплата газа непосредственно из кошелька

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

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

Мы обновим метод validateOp кошелька, чтобы точка входа могла запросить у него средства и отклонить операцию, если validateOp не выплатит запрашиваемую сумму точке входа:

contract Wallet {
function validateOp(UserOperation op, uint256 requiredPayment);
function executeOp(UserOperation op);
}


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

Но здесь мы сталкиваемся с проблемой.

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

Вместо этого мы оставим их у себя и позволим кошельку получить их, сделав вызов для снятия денег позже. Это и есть схема pull-payment.

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

Оказывается, система пополнения/снятия средств нам все-таки была нужна (или, по крайней мере, ее часть).

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

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

#accountabstraction
1
Account abstraction (ERC-4337). Часть 9

Стимулы для исполнителей

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

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

Мы добавим поле в пользовательские операции, чтобы выразить это:

struct UserOperation {
// ...
uint256 maxPriorityFeePerGas;
}


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

Исполнитель, отправляя свою транзакцию для вызова handleOp, может выбрать меньшее значение maxPriorityFeePerGas и сэкономить на разнице.

Точка входа как синглтон (singleton)

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

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

struct UserOperation {
// ...
address sender;
}


Без отдельного EOA

Нашей целью было создать внутрицепочечный кошелек, который платит за свой газ без необходимости управления отдельным EOA, и теперь мы достигли этой цели!

У нас есть кошелек с интерфейсом:

contract Wallet {
function validateOp(UserOperation op, uint256 requiredPayment);
function executeOp(UserOperation op);
}


У нас также есть точка входа с интерфейсом для всего блокчейна:

contract EntryPoint {
function handleOp(UserOperation op);
function deposit(address wallet) payable;
function withdrawTo(address destination);
}


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

Исполнитель симулирует метод validateOp кошелька на этой пользовательской операции, чтобы решить, принять ее или нет.

Если он принимает, исполнитель отправляет транзакцию в точку входа для вызова handleOp.

Затем точка входа обрабатывает проверку и выполнение операции на цепи, после чего возвращает ETH исполнителю из средств, внесенных в кошелек.

Это было долго, но мы справились!

#accountabstraction
Account abstraction (ERC-4337). Часть 10

Сегодня, наконец, мы закончим читать первую статью из 4 про Account Abstraction и перейдем к следующим. Напомню, что в планах разобрать каждую, а потом изучить видео от Патрика, разобрав примеры.

Итак, продолжим.

Пакетирование (Bundling)

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

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

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

Для этого потребуется совсем немного изменений.

Мы заменим:

contract EntryPoint {
function handleOp(UserOperation op);
}


На:

contract EntryPoint {
function handleOps(UserOperation[] ops);
}


И, по сути, это все!

Новый метод handleOps делает примерно то же самое, что вы ожидали:

1. Для каждой операции вызываем validateOp на кошельке-отправителе. Все операции, которые не прошли проверку, мы отбрасываем.

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

Здесь стоит отметить, что мы сначала выполняем все проверки и только потом выполняем все операции, а не проверяем и выполняем каждую операцию, прежде чем переходить к следующей.

Это важно для сохранения симуляций.

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

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

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

Для исполнителей это новый источник дохода!

У исполнителя появилась возможность получить максимальную извлекаемую ценность (MEV), организуя пользовательские операции в связке (и, возможно, вставляя свои собственные) таким образом, чтобы это приносило прибыль.

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

Я буду называть их бандлерами до конца этой серии из 4 частей, чтобы соответствовать терминологии ERC-4337, но на самом деле я считаю, что "исполнитель" - это хороший способ думать о них, потому что это подчеркивает, что их работа заключается в том, чтобы быть тем, кто фактически начинает исполнение на цепи, отправляя транзакцию из EOA.

#accountabstraction
Account abstraction (ERC-4337). Часть 11

Бандлеры как участники сети

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

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

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

Со временем мы можем ожидать, что создатели пакетов и блоков объединятся в одну роль.

Далее мы поговорим о спонсорских сделках с использованием Paymasters.

#accountabstraction
1