Bundler в ERC-4337: отправка UserOperation
Оглавление курса «Web3: вход, смарт-аккаунты и индексатор» · Урок 23 из 30.
В предыдущем уроке мы разделили разрешение операции и выполнение вызова реестра. Но между браузером преподавателя и EntryPoint существует ещё один участник: сервис, который принимает операции и доставляет их в сеть. В ERC-4337 эту роль выполняет bundler. Теперь построим интерфейс доставки, который сохраняет неопределённость, допускает восстановление после обрыва связи и не превращает HTTP-ответ в доказательство публикации курса 17.
В статье показан договор адаптера и чистая модель состояния. Реального bundler, сетевых запросов, подписи и операций мы не запускали. Собственный формат объектов ниже не является готовой схемой JSON-RPC. Для рабочей реализации потребуется выбрать согласованные версии аккаунта, EntryPoint и сервиса, затем закрепить их формат и метод вычисления идентификатора операции.
Что доказывает принятие запроса
Bundler получает подготовленную операцию, проверяет её пригодность для доставки и может включить её вместе с другими операциями в сетевую транзакцию. Принятие сервиса означает только то, что сервис обработал запрос на своём уровне. Оно не заменяет наблюдение результата в цепочке. Этот механизм описан в спецификации ERC-4337.
У преподавателя есть намерение заменить хеш курса 17. Представим, что сервис принял запрос, но через несколько секунд выяснилось, что авторизация просрочена или доступный бюджет недостаточен. Карточка «курс опубликован» в момент принятия была бы ложной. Правильный текст говорит «операция передана; ожидаем включения» и оставляет текущую версию курса видимой до появления фактического изменения.
Ещё сложнее случай, когда ответ потерялся. Браузер отправил байты, соединение прервалось, и пользователь не знает, успел ли сервис их принять. Ошибка сети не доказывает отказ. Поэтому полезно получить или вычислить устойчивый идентификатор подготовленной операции до отправки, если выбранный профиль позволяет сделать это достоверно. Этот идентификатор вычисляет официальный адаптер, а не произвольный SHA-256 от нашего intent.
Запись доставки
Сохраним вместе с операцией краткий паспорт: локальный идентификатор попытки, окружение, адрес аккаунта, целевое намерение и идентификатор сетевой операции. Подпись может быть чувствительным разрешением; её хранение и сроки удаления определяются отдельно. Для обычного журнала интерфейса достаточно идентификаторов и безопасного описания действия.
export function preparedDelivery({ preparationId, intent, operationHash }) {
return {
preparationId,
environmentId: intent.environmentId,
sender: intent.sender,
courseId: intent.courseId.toString(),
expectedContentHash: intent.contentHash,
operationHash,
state: 'prepared',
lastError: null,
};
}
export async function deliver(record, adapter, signedOperation) {
try {
const accepted = await adapter.send(signedOperation);
if (accepted.operationHash !== record.operationHash) {
return { ...record, state: 'inconsistent', lastError: 'hash-mismatch' };
}
return { ...record, state: 'accepted', lastError: null };
} catch (error) {
return { ...record, state: 'unknown', lastError: adapter.classify(error) };
}
}
Методы adapter.send и adapter.classify здесь являются нашим договором. Их ещё надо реализовать для выбранной инфраструктуры. В частности, отказ в проверке и транспортный тайм-аут должны различаться. В компактном примере любой перехваченный сбой становится unknown, чтобы отсутствие этой классификации не породило необоснованное утверждение об отказе.
Если сервис возвращает другой идентификатор, не исправляем сохранённый hash молча. Это несогласованность, которую должен разобрать адаптер: ошибка версии, другая операция, неверный способ упаковки или некорректный ответ. Пока связь идентификаторов не доказана, искать результат по новому значению и объявлять его нашей публикацией нельзя.
Повтор просмотра и повтор действия
Кнопка «Проверить статус» читает результат исходной операции. Кнопка «Отправить новую» формирует новое намерение, запрашивает актуальные условия и отдельное разрешение. Эти действия должны отличаться и в коде, и в тексте. Иначе пользователь способен случайно выполнить публикацию дважды, хотя хотел только узнать судьбу первой отправки.
Точное повторение одной подписанной операции с тем же идентификатором не равнозначно созданию операции с новым nonce. Однако поведение повторного запроса всё равно зависит от выбранного сервиса: он может сообщить, что операция уже известна, отклонена или включена. Приложение использует такой ответ для восстановления наблюдения, а не делает универсальное обещание доставки.
После перезагрузки страницы загружаем запись prepared или unknown и запрашиваем её статус. Если операция пока не найдена, это ограниченное наблюдение данного сервиса в данный момент. Другой bundler или сеть могут знать больше. Не найденная запись не становится автоматически разрешением повторно изменить состояние. Пользователю показываем доступные источники и причину неопределённости.
Окружение сильнее адреса сервиса
Смена сервиса допустима только при совместимом профиле. Новый endpoint должен обслуживать ту же сеть, поддерживаемый EntryPoint и выбранную форму операции. Даже совпадение адреса аккаунта не гарантирует этого. В локальной лаборатории после сброса узла окружение получает новый environmentId: прежняя история доставки не должна приклеиваться к новым контрактам.
Адрес сервиса не берём из произвольного параметра URL. Иначе внешний участник сможет подсунуть endpoint, который записывает подписи, подменяет ответы или просит дополнительные действия. Конфигурация является частью приложения и проходит ту же проверку, что адрес CourseCatalog. Это не делает сервис безусловно доверенным: результат всё равно сопоставляется с цепочкой.
Полезно ограничить размер запросов и число одновременных ожиданий. Нельзя создавать отдельный бесконечный polling для каждого нажатия. Одна операция имеет один наблюдатель в данном контексте, а повторное открытие карточки присоединяется к уже существующему наблюдению. Истёкший предел ожидания переводит запись в неизвестное состояние, сохраняя возможность дальнейшего поиска.
Ожидаемая последовательность
В нормальном сценарии запись проходит prepared, accepted и затем получает отдельное подтверждение включения. До завершения карточка показывает адрес аккаунта, курс 17 и ожидаемый хеш. Номер внешней транзакции добавляется позже, когда он действительно связан с операцией. Только результат исполнения позволяет перейти к перечитыванию реестра.
При потере HTTP-ответа запись становится unknown. Поле expectedContentHash остаётся прежним, новый nonce не выдаётся. При следующем открытии страницы приложение спрашивает адаптер о сохранённом operationHash. Если найдено включение, оно продолжает обычный маршрут. Если нет, интерфейс объясняет, что подтверждения пока недостаточно.
При отказе из-за несовместимого EntryPoint запись получает диагностическую причину уровня конфигурации. Повторное нажатие не исправит профиль. Преподаватель не должен видеть совет увеличить оплату, когда сервис вообще не понимает формат запроса. Так мы получаем понятную границу: доставка может сообщить о ходе операции, но не имеет права сама объявлять изменение CourseCatalog.
Сохранённая запись доставки также помогает после закрытия вкладки. Наблюдение возобновляется по известному идентификатору и окружению. Отсутствие браузерного таймера не означает отмену сетевой операции.