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 → аккаунт
Клиент собирает и подписывает UserOperation
↓
Отправка в alt-mempool: eth_sendUserOperation(userOp, entryPoint)
↓
Bundler валидирует, пакует, шлёт обычную TX
↓
EntryPoint.handleOps([...]) ← from = EOA bundler'а
↓
Smart-аккаунт: validateUserOp → исполнение callDataBundler — инфраструктурный узел с 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).
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.
Что дальше
- Paymaster: как оплатить газ за пользователя — спонсорство газа через verifying paymaster
- Smart-аккаунт vs EOA: в чём разница на практике — сравнение подписей, батчинга и стоимости
- Что такое Account Abstraction простыми словами — зачем AA нужна продукту, без деталей ERC-4337