китаев.tech

ERC-4337: как устроена транзакция без изменения протокола Ethereum

ERC-4337 — стандарт Account Abstraction без апгрейда консенсуса Ethereum. Разбираем UserOperation, Bundler и EntryPoint на реальном примере.

Почему протокол Ethereum не меняли

Account Abstraction можно было бы вшить в консенсус (протокол сам принимает «транзакции от контракта»). ERC-4337 идёт другим путём: смарт-аккаунт, mempool намерений и singleton-контракт EntryPoint живут поверх существующего Ethereum.

Обычная транзакция по-прежнему начинается с EOA. Разница в том, что эту транзакцию шлёт не пользователь, а Bundler — и внутри неё EntryPoint исполняет пачку UserOperation.


UserOperation — не Ethereum-транзакция

Пользователь (или его SDK) подписывает не legacy/EIP-1559 TX, а объект намерения — UserOperation.

В EntryPoint v0.7 bundler принимает распакованный (unpacked) формат. Упаковку в on-chain PackedUserOperation делает bundler перед вызовом EntryPoint.

Поле (v0.7)Зачем
senderАдрес smart-аккаунта
nonceАнти-replay; в v0.7 — key + sequence в одном uint256
factory / factoryDataДеплой аккаунта при первом UserOp (вместо initCode из v0.6)
callDataЧто выполнить на аккаунте (часто execute / executeBatch)
gas-поляЛимиты verification / call / preVerification + EIP-1559 fees
paymaster*Опционально: кто спонсирует газ
signatureПодпись по правилам этого аккаунта

Canonical EntryPoint v0.7 на многих сетях:

0x0000000071727De22E5E9d8BAf0edAc6f37da032


Путь: UserOperation → Bundler → EntryPoint → аккаунт

text
Клиент собирает и подписывает UserOperation

Отправка в alt-mempool: eth_sendUserOperation(userOp, entryPoint)

Bundler валидирует, пакует, шлёт обычную TX

EntryPoint.handleOps([...])  ← from = EOA bundler'а

Smart-аккаунт: validateUserOp → исполнение callData

Bundler — инфраструктурный узел с ERC-4337 RPC (eth_sendUserOperation, eth_estimateUserOperationGas, eth_supportedEntryPoints). Примеры: Pimlico, Alchemy Account Kit.

EntryPoint — единственный on-chain «шлюз»: проверяет депозиты/paymaster, вызывает аккаунт, списывает газ. Пользовательский EOA в эту цепочку не обязан ходить напрямую.


Собираем UserOperation и вызываем Bundler RPC

Ниже — минимальный путь на ethers v6: читаем nonce у EntryPoint, собираем unpacked UserOperation v0.7 и отправляем его через публичный Bundler RPC (Pimlico). Для реального исполнения нужны уже задеплоенный (или factory) smart-аккаунт, валидная signature и оплата газа (депозит в EntryPoint или Paymaster).

typescript
import { ethers } from "ethers";

const ENTRY_POINT_V07 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032";

// Обычный RPC — только чтение EntryPoint
const eth = new ethers.JsonRpcProvider("https://ethereum.publicnode.com");

// Bundler RPC: Pimlico (chainId 1 = Ethereum). Ключ — в dashboard.pimlico.io
const bundlerRpc = process.env.BUNDLER_RPC;
if (!bundlerRpc) {
  throw new Error(
    "Задайте BUNDLER_RPC, например https://api.pimlico.io/v2/1/rpc?apikey=...",
  );
}
const bundler = new ethers.JsonRpcProvider(bundlerRpc);

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

// Адрес уже существующего smart-аккаунта (Contract Account с логикой ERC-4337)
const sender = process.env.SMART_ACCOUNT_ADDRESS;
if (!sender || !ethers.isAddress(sender)) {
  throw new Error("Задайте SMART_ACCOUNT_ADDRESS — адрес smart-аккаунта");
}

const code = await eth.getCode(sender);
if (code === "0x") {
  throw new Error("sender должен быть контрактом; для первого деплоя нужны factory + factoryData");
}

const nonce = await entryPoint.getNonce(sender, 0);

// Типичный callData кошелька: execute(target, value, data)
const recipient = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
const callData = new ethers.Interface([
  "function execute(address target, uint256 value, bytes calldata data)",
]).encodeFunctionData("execute", [recipient, 0n, "0x"]);

const feeData = await eth.getFeeData();
const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.parseUnits("0.05", "gwei");
const maxFeePerGas =
  feeData.maxFeePerGas ?? maxPriorityFeePerGas + ethers.parseUnits("1", "gwei");

// Unpacked UserOperation для EntryPoint v0.7 (так ждёт eth_sendUserOperation)
const userOperation = {
  sender,
  nonce: ethers.toBeHex(nonce),
  // factory / factoryData — только если аккаунт ещё не задеплоен
  callData,
  callGasLimit: ethers.toBeHex(200_000),
  verificationGasLimit: ethers.toBeHex(150_000),
  preVerificationGas: ethers.toBeHex(50_000),
  maxFeePerGas: ethers.toBeHex(maxFeePerGas),
  maxPriorityFeePerGas: ethers.toBeHex(maxPriorityFeePerGas),
  // без Paymaster поля paymaster* можно опустить / передать null — зависит от bundler
  signature: "0x", // сюда — реальная подпись по правилам вашего аккаунта
};

const supported = await bundler.send("eth_supportedEntryPoints", []);
console.log("Bundler EntryPoints:", supported);

if (
  !supported
    .map((a: string) => a.toLowerCase())
    .includes(ENTRY_POINT_V07.toLowerCase())
) {
  throw new Error("Этот bundler не объявил EntryPoint v0.7");
}

// Отправка в alt-mempool. С signature: "0x" bundler отклонит — сначала подпишите UserOp.
const userOpHash = await bundler.send("eth_sendUserOperation", [
  userOperation,
  ENTRY_POINT_V07,
]);

console.log("userOpHash:", userOpHash);
console.log("Дальше: eth_getUserOperationReceipt(userOpHash) → txHash бандла");

userOpHash — идентификатор намерения в AA-mempool, не путать с transactionHash бандла. Когда bundler включит UserOp в блок, receipt свяжет hash операции с обычной TX к EntryPoint.


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

entryPoint.getNonce(sender, 0) — nonce smart-аккаунта в схеме EntryPoint (ключ 0 + последовательность), не nonce EOA в протоколе.

factory / factoryData — аналог старого initCode из EntryPoint v0.6: если sender ещё без bytecode, EntryPoint сначала деплоит аккаунт через factory.

bundler.send("eth_sendUserOperation", [userOp, entryPoint]) — JSON-RPC метод alt-mempool. Обычный eth_sendRawTransaction сюда не подходит: UserOp не является подписанной Ethereum-транзакцией пользователя.

EntryPoint.handleOps — то, что bundler в итоге вызывает своей EOA-транзакцией. Протокол видит обычный вызов контракта; AA-логика — внутри EntryPoint и аккаунта.


Частые ошибки

Отправлять UserOp через eth_sendRawTransaction → UserOperation идёт только в bundler (eth_sendUserOperation). В сеть её вкладывает EOA bundler'а вызовом EntryPoint.

Смешать форматы v0.6 и v0.7 → в v0.6 были initCode и paymasterAndData; в v0.7 — factory/factoryData и отдельные поля paymaster. Адрес EntryPoint тоже другой (0x5FF1… vs 0x0000000071727D…).

Занизить verificationGasLimit → валидация подписи и деплой через factory дороже простого transfer. Сначала eth_estimateUserOperationGas, потом send.

Путать userOpHash и hash транзакции бандла → статус UserOp смотрите через eth_getUserOperationByHash / eth_getUserOperationReceipt, не только через обычный getTransactionReceipt.


Что дальше

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