Solidity. Смарт контракты и аудит – Telegram
Solidity. Смарт контракты и аудит
2.62K subscribers
246 photos
7 videos
18 files
547 links
Обучение Solidity. Уроки, аудит, разбор кода и популярных сервисов
Download Telegram
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
Account abstraction (ERC-4337). Часть 12

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

А что если бы, газ мог заплатить не владелец кошелька, а кто-то другой? И мы переходим ко второй статье от Alchemy: Account Abstraction Part 2: Sponsoring Transactions Using Paymasters.

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

1. Если владелец кошелька - новичок в блокчейне, то необходимость приобретения ETH перед выполнением действий на цепочке станет огромным камнем преткновения;

2. Dapp может быть готов оплачивать бензин для своих методов, чтобы плата за газ не отпугивала потенциальных пользователей;

3. Спонсор может позволить кошельку оплачивать газ в каком-либо токене, отличном от ETH, например, в USDC;

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

Представляем paymasters

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

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

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

contract Paymaster {
function validatePaymasterOp(UserOperation op);
}


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

Мы добавим новое поле в UserOperation, чтобы обозначить это.

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

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

struct UserOperation {
// ...
address paymaster;
bytes paymasterData;
}


Далее мы изменим handleOps, чтобы использовать новые paymasters.

Теперь ее поведение будет таким для каждой операции:

1. Вызываем validateOp на кошельке, указанном отправителем операции;

2. Если у операции есть адрес paymaster, то вызовается validatePaymasterOp для этого paymaster;

3. Все операции, которые не прошли проверку, отбрасываются;

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

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

На самом деле это довольно просто, верно?

Мы просто попросим бандлер обновить свои симуляции и...

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

Paymaster staking

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

Здесь возникает та же проблема:

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

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

Но здесь есть одна загвоздка.

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

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

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

Вредоносный paymaster может использовать это для DoS-атаки на систему.

Чтобы предотвратить это, мы ввели систему репутации.

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

Такая система может не сработать, в случае если вредоносный paymaster просто создаст множество своих копий (атака Sybil). Именно поэтому мы потребуем, чтобы paymaster ставили на кон свой Эфир. Таким образом, наличие нескольких аккаунтов не принесет ему выгоды.

Давайте добавим новые методы для работы со стейкингом:

contract EntryPoint {
// ...

function addStake() payable;
function unlockStake();
function withdrawStake(address payable destination);
}


Как только сделан депозит Эфира, он не может быть снят до тех пор, пока не пройдет некоторая задержка (delay) после вызова unlockStake.

Эти новые методы отличаются от ранее рассмотренных функций deposit и withdrawTo, которые используются кошельками и paymaster'ами для пополнения ETH, и могут быть использованы для оплаты газа или немедленно сняты в любой момент.

Существует исключений из правил:

Если paymaster обращается только к связанному с кошельком хранилищу, а не к своему собственному, то ему не нужно делать ставку (stake ETH), потому что в этом случае, хранилища, к которым обращаются несколько операторов в связке, не будут пересекаться друг с другом.

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

P.S. В отличие от многих других схем ставок (stake ETH), здесь ставки никогда не снижаются. Они существуют просто как способ заставить потенциального злоумышленника заблокировать очень большую сумму капитала для проведения масштабной атаки.


#accountabstraction
1
Закрытый канал для разбора отчетов

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

На канале делаются посты в формате:

Протокол Panoptic

[M-03] CREATE2 address collision during pool deployment allows for complete draining of the pool

Ссылка на репорт:
https://code4rena.com/reports/2024-04-panoptic#m-03-create2-address-collision-during-pool-deployment-allows-for-complete-draining-of-the-pool

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

Для начала посмотрите на код:


   function deployNewPool(address token0, address token1, uint24 fee, bytes32 salt)
external
returns (PanopticPool newPoolContract)
{
...
// This creates a new Panoptic Pool (proxy to the PanopticPool implementation)
// Users can specify a salt, the aim is to incentivize the mining of addresses with leading zeros
@> newPoolContract = PanopticPool(POOL_REFERENCE.cloneDeterministic(salt));
...
}


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

Грубо говоря, cloneDeterministic() использует create2 опкод для расчета адреса нового пула. Также может сделать и хакер.

Он рассчитывает адрес пула (вот тут и нужно понимание хеширования, о котором я писал выше) и делает вот что.

1. Делает деплой контракта, раздает в различные токены апруву на перевод самому хакеру, и с помощью selfdestruct() уничтожает этот контракт.
2. Не смотря на обновление Денкун, selfdestruct все еще можно выполнить, если он происходит в одной транзакции с созданием контракта.

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

Такая вот необычная атака!

Как рекомендация от аудитора, было добавить в соль другие параметры от функции, типа block.timestamp или block.number, чтобы сделать соль более случайной.


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

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

Однако сейчас мы в небольшом отпуске и посты выходят не несколько раз в день, а 4-5 в неделю. Поэтому у вас есть возможность присмотреться к такому формату для себя и получить доступ до начала октября за 1000 рублей.

Обычно это цена за один месяц, но сейчас вы получите доступ на два месяца.

Всем хорошего дня и безопасного кода!

#bugs
🔥63👍1
Account abstraction (ERC-4337). Часть 14

Улучшение: Paymaster postOp

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

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

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

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

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

Наша первая попытка создать postOp будет выглядеть следующим образом:

contract Paymaster {
function validatePaymasterOp(UserOperation op) returns (bytes context);
function postOp(bytes context, uint256 actualGasCost);
}


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

Допустим, перед тем как одобрить выполнение операции (в validatePaymasterOp), paymaster проверил, достаточно ли у пользователя, например, USDC для оплаты операции. Но вполне возможно, что во время выполнения операция отдаст все USDC кошелька, что будет означать, что платежная система не сможет извлечь оплату в конце.

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

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

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

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

Если это происходит, то контракт снова вызывает postOp, но теперь мы оказываемся в ситуации, в которой были до выполнения executeOp, и поскольку в этой ситуации мы только что проверили validatePaymasterOp, paymaster должен быть в состоянии извлечь свою прибыль.

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

contract Paymaster {
function validatePaymasterOp(UserOperation op) returns (bytes context);
function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost);
}


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

Подведем итоги.

Как paymaster позволяют выполнять спонсируемые транзакции?


Для того чтобы позволить кому-то, кроме владельца кошелька, оплачивать газ, мы вводим новый тип субъекта - paymaster, который развертывает смарт-контракт со следующим интерфейсом:

contract Paymaster {
function validatePaymasterOp(UserOperation op) returns (bytes context);
function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost);
}


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

struct UserOperation {
// ...
address paymaster;
bytes paymasterData;
}


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

Контракт обновляет свой метод handleOps таким образом, чтобы для каждой операции, помимо проверки кошелька через validateOp, она также проверяла paymaster операции (если таковые имеются) через validatePaymasterOp, затем выполняла операцию и, наконец, вызывала postOp.

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

Для этого используется несколько новых методов:


contract EntryPoint {
// ...

function addStake() payable;
function unlockStake();
function withdrawStake(address payable destination);
}


Дальше больше! Мы переходим к третьей статье!

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

Создание кошелька

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

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

Есть и другая, менее очевидная цель, которая также довольно важна.

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

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

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

Определение адресов контрактов с помощью CREATE2

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

P.S. Адрес, на который контракт в конечном итоге будет развернут, но еще этого не произошло, называется контрфактическим адресом (counterfactual address).

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

- Адрес контракта, вызывающего CREATE2;

- Соль, которая может быть любым 32-байтовым значением;

- Код инициации развертываемого контракта;

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

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

На этой неделе мы заканчиваем с переводом и разбором статей Alchemy, посвященные Account Abstraction. И сегодня продолжим говорить о создании Кошельков.

Первая попытка: Развертывание произвольных контрактов

Теперь, когда мы знаем о CREATE2, наш изначальный план прост. Мы позволим пользователям передавать код инициации (init code), а точка входа будет разворачивать его. Для начала мы добавим еще одно поле в пользовательские операции:

struct UserOperation {
// ...
bytes initCode;
}


Затем мы обновим часть валидации в handleOps, чтобы сделать следующее:

В рамках проверки пользовательской операции, если операция имеет непустой initCode, то будет использоваться CREATE2 для развертывания контракта с этим init-кодом.

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

1. Вызываем метод validateOp только что созданного кошелька;

2. Затем, если у операции есть paymaster, вызываем метод validatePaymasterOp в paymaster;

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

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

1. Когда paymaster просматривает пользовательские операции, он не может проанализировать байткод, чтобы решить, хочет ли он платить за него или нет;

2. Когда пользователь отправляет байткод для развертывания контракта, он не может легко проверить, что отправленный им байткод выполняет то, что он хочет. Если пользователь использует инструмент для развертывания контракта, то, если инструмент вредоносный или взломанный, он может предоставить код init, который установит бэкдор в развернутый контракт, и этот баг будет сложно обнаружить;

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

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

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

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

Вторая попытка: Внедрение фабрик

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

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

Фабрики будут иметь метод, который можно вызвать для создания контракта:

contract Factory {
function deployContract(bytes data) returns (address);
}


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

struct UserOperation {
// ...
address factory;
bytes factoryData;
}


Это решает первые две проблемы из предыдущих постов:

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

2. Paymaster могут выбирать для оплаты развертывания определенные одобренные фабрики;

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

Это именно та проблема, с которой мы столкнулись в методе validatePaymasterOp у paymasters, и мы решим ее тем же способом.

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

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

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

На данный момент созданная нами архитектура может выполнять все функции настоящего ERC-4337!

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

#accountabstraction