китаев.tech

Частые ошибки при переходе на Account Abstraction

Разбираем типичные ошибки при внедрении ERC-4337: неверная оценка газа UserOperation, путаница initCode, проблемы с nonce у смарт-аккаунтов.

Где чаще всего ломается внедрение ERC-4337

Переход с EOA на Account Abstraction редко падает на «непоняли идею». Обычно ломается три места: газ UserOperation, деплой через initCode/factory и nonce smart-аккаунта.

Ниже — разбор с диагностикой в коде. Путь UserOp → Bundler → EntryPoint — в статье про ERC-4337.


Неверная оценка газа UserOperation

У EOA часто хватает estimateGas + запас. У UserOp три отдельных лимита:

ПолеЗа что отвечает
verificationGasLimitvalidateUserOp, проверка подписи, иногда деплой
callGasLimitИсполнение callData на аккаунте
preVerificationGasCompensация bundler'у за calldata / pre-checks

Типичный провал: скопировать gasLimit: 21000 с EOA-transfer или занизить verificationGasLimit. Bundler отклоняет UserOp на симуляции (AA21, AA23, out of gas на validation) ещё до включения в блок.

Всегда сначала eth_estimateUserOperationGas, потом подставляете лимиты и только после этого подписываете и шлёте eth_sendUserOperation.

typescript
import { ethers } from "ethers";

const ENTRY_POINT_V07 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032";

const bundlerRpc = process.env.BUNDLER_RPC;
if (!bundlerRpc) {
  throw new Error("Задайте BUNDLER_RPC (Pimlico / Alchemy AA endpoint)");
}

const bundler = new ethers.JsonRpcProvider(bundlerRpc);

// Минимальный каркас UserOp v0.7 до оценки газа.
// signature: «dummy» нужен многим bundler'ам для estimate (длина как у реальной ECDSA).
const userOperation = {
  sender: process.env.SMART_ACCOUNT_ADDRESS as string,
  nonce: "0x0",
  callData: "0x",
  callGasLimit: "0x0",
  verificationGasLimit: "0x0",
  preVerificationGas: "0x0",
  maxFeePerGas: ethers.toBeHex(ethers.parseUnits("1", "gwei")),
  maxPriorityFeePerGas: ethers.toBeHex(ethers.parseUnits("0.1", "gwei")),
  signature:
    "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c",
};

if (!userOperation.sender || !ethers.isAddress(userOperation.sender)) {
  throw new Error("Задайте SMART_ACCOUNT_ADDRESS");
}

const gas = await bundler.send("eth_estimateUserOperationGas", [
  userOperation,
  ENTRY_POINT_V07,
]);

console.log("preVerificationGas:", gas.preVerificationGas);
console.log("verificationGasLimit:", gas.verificationGasLimit);
console.log("callGasLimit:", gas.callGasLimit);

// Подставьте gas.* в UserOp, затем реальную signature — и только потом eth_sendUserOperation

Если estimate падает, а send «как-нибудь» проходит с огромными лимитами — вы маскируете баг валидации или callData. Чините причину, не раздувайте gas вслепую.


Путаница initCode и factory

В EntryPoint v0.6 первый деплой шёл через одно поле initCode (= factory || factoryData).

В v0.7 то же самое разбито на factory + factoryData. Bundler ждёт unpacked-формат; на EntryPoint он сам упакует.

ОшибкаСимптом
Шлёте initCode на EntryPoint v0.7 / bundler v0.7Reject / неизвестное поле
factory+factoryData при уже задеплоенном senderЛишний деплой или revert
Пустые factory-поля, а getCode(sender) === "0x"Аккаунт не существует → validation fail
sender ≠ CREATE2-адрес от factory+данных«Sender address mismatch» / AA14

Правило: перед сборкой UserOp проверьте bytecode.

typescript
import { ethers } from "ethers";

const eth = new ethers.JsonRpcProvider("https://ethereum.publicnode.com");
const sender = process.env.SMART_ACCOUNT_ADDRESS as string;

const code = await eth.getCode(sender);
const deployed = code !== "0x";

console.log("sender:", sender);
console.log("deployed:", deployed);

if (!deployed) {
  console.log(
    "Нужны factory + factoryData (v0.7) или initCode (v0.6) — иначе EntryPoint не найдёт аккаунт",
  );
} else {
  console.log("factory/factoryData не передавайте — аккаунт уже on-chain");
}

Адрес counterfactual-аккаунта должен совпадать с тем, что вернёт factory (getAddress / createAccount preview в SDK). Ручной sender «на глаз» — частая причина ночных дебагов.


Проблемы с nonce у smart-аккаунтов

Nonce EOA — счётчик обычных транзакций: provider.getTransactionCount(address).

У ERC-4337 nonce другой: его хранит EntryPoint (схема key + sequence). getTransactionCount(smartAccount) для UserOp не подходит.

typescript
import { ethers } from "ethers";

const ENTRY_POINT_V07 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032";
const eth = new ethers.JsonRpcProvider("https://ethereum.publicnode.com");

const entryPoint = new ethers.Contract(
  ENTRY_POINT_V07,
  ["function getNonce(address sender, uint192 key) view returns (uint256)"],
  eth,
);

const sender = process.env.SMART_ACCOUNT_ADDRESS as string;
if (!sender) throw new Error("Задайте SMART_ACCOUNT_ADDRESS");

const eoaStyleNonce = await eth.getTransactionCount(sender);
const aaNonceKey0 = await entryPoint.getNonce(sender, 0);
const aaNonceKey1 = await entryPoint.getNonce(sender, 1);

console.log("getTransactionCount (не для UserOp):", eoaStyleNonce);
console.log("EntryPoint nonce key=0:", aaNonceKey0.toString());
console.log("EntryPoint nonce key=1:", aaNonceKey1.toString());

Типичные провалы:

  • Подставить 0 всегда → после первого UserOp bundler отвечает AA25 / nonce error.
  • Параллельные UserOp с одним и тем же key+sequence → один пройдёт, остальные отвалятся (как у EOA с одним nonce, но источник другой).
  • Путать nonce bundler EOA (обычный TX к EntryPoint) с nonce sender в UserOp — это вообще разные аккаунты.

Для параллельных потоков используйте разные nonce key (2D nonce), если аккаунт и SDK это поддерживают — не крутите один sequence вручную с гонками.


Как это работает

eth_estimateUserOperationGas — симуляция на стороне bundler: validation + call. Результат привязан к текущему callData, signature-заглушке и состоянию сети; после смены callData оценку повторяют.

factory / factoryData срабатывают только если sender ещё без кода. EntryPoint деплоит аккаунт в verification-фазе, поэтому verification gas на первом UserOp заметно выше.

EntryPoint.getNonce(sender, key) — канонический источник nonce для UserOp. SDK (permissionless, Account Kit, ZeroDev) обычно подставляют его сами; ручная сборка UserOp без getNonce почти всегда ошибочна.


Чеклист перед eth_sendUserOperation

Газ «с потолка» → сначала eth_estimateUserOperationGas, потом подпись.

v0.6-поля на v0.7 EntryPointinitCode / paymasterAndData замените на factory+factoryData и отдельные paymaster*.

getTransactionCount для UserOp → берите EntryPoint.getNonce.

Нет ETH на аккаунте и нет Paymaster → bundler не включит UserOp; нужен депозит в EntryPoint или спонсор.

Путать userOpHash и hash бандла → статус через eth_getUserOperationReceipt, не только getTransactionReceipt.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.