китаев.tech

Smart-аккаунт vs EOA: в чём разница на практике

Сравниваем smart-аккаунт (ERC-4337) и обычный EOA-кошелёк: подписи, восстановление доступа, батчинг транзакций, стоимость газа.

Два кошелька, одна сеть

EOA — адрес без кода: один ECDSA-ключ подписывает обычную Ethereum-транзакцию (MetaMask, Rabby, Ledger).

Smart-аккаунт (обычно ERC-4337) — контракт-кошелёк: правила подписи, батчинга и оплаты газа задаёт код. Пользователь подписывает UserOperation, в сеть её доставляет Bundler через EntryPoint.

База типов аккаунтов — в EOA vs Contract Account. Здесь — что это меняет в продукте и в коде.


Сравнение на практике

КритерийEOASmart-аккаунт (ERC-4337)
ПодписьТолько secp256k1 ECDSAЛюбая логика аккаунта: ECDSA, мультиподпись, passkey, session keys
Восстановление доступаНовый ключ / seed; старый адрес не «починить»Social recovery, смена владельца, гильдии опекунов — если заложено в контракт
МультиподписьОтдельный контракт (Safe и т.п.) поверх EOA-подписантовМожно встроить в сам аккаунт
БатчингОдна цель на TX (или свой multicall)Несколько вызовов в одном callData UserOp
Кто платит газEOA должен иметь ETHДепозит аккаунта в EntryPoint и/или Paymaster
ОнбордингРасширение / seed / «купи ETH на газ»Часто email + gasless (с Paymaster / embedded wallet)
СтоимостьДешевле на простой переводДороже verification + EntryPoint; окупается батчингом и UX
Кто шлёт в сетьСам кошелёк (eth_sendRawTransaction)Bundler → EntryPoint.handleOps

Smart-аккаунт — частный случай Contract Account. Не каждый контракт с bytecode — кошелёк ERC-4337.


Одна задача — два пути в коде

Сценарий: перевести ETH. У EOA это одна подписанная TX. У smart-аккаунта — UserOperation с execute в callData (исполнит EntryPoint после валидации).

typescript
import { ethers } from "ethers";

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

// --- EOA: одна обычная транзакция ---
async function eoaTransfer(privateKey: string) {
  const wallet = new ethers.Wallet(privateKey, provider);
  const code = await provider.getCode(wallet.address);
  if (code !== "0x") throw new Error("Ожидали EOA без bytecode");

  // На mainnet это реальная отправка. Для сухого прогона достаточно собрать TX:
  const tx = {
    to: recipient,
    value: ethers.parseEther("0.01"),
    maxFeePerGas: ethers.parseUnits("20", "gwei"),
    maxPriorityFeePerGas: ethers.parseUnits("1", "gwei"),
    gasLimit: 21_000n,
    nonce: await provider.getTransactionCount(wallet.address),
    chainId: (await provider.getNetwork()).chainId,
    type: 2,
  };

  const raw = await wallet.signTransaction(tx);
  console.log("EOA from:", wallet.address);
  console.log("EOA raw TX length:", raw.length);
  console.log("Газ платит:", wallet.address);
  // await provider.broadcastTransaction(raw);
}

// --- Smart-аккаунт: батч approve + вызов в одном UserOp ---
async function smartAccountBatch(smartAccount: string) {
  const code = await provider.getCode(smartAccount);
  if (code === "0x") throw new Error("Smart-аккаунт должен иметь bytecode");

  const iface = new ethers.Interface([
    "function executeBatch(address[] targets, uint256[] values, bytes[] datas)",
    "function approve(address spender, uint256 amount)",
  ]);

  // Пример: USDC mainnet + «пустой» второй вызов (в проде — swap / deposit)
  const usdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
  const spender = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"; // Uniswap V2 Router

  const targets = [usdc, recipient];
  const values = [0n, 0n];
  const datas = [
    iface.encodeFunctionData("approve", [spender, 1_000_000n]), // 1 USDC (6 decimals)
    "0x",
  ];

  const callData = iface.encodeFunctionData("executeBatch", [
    targets,
    values,
    datas,
  ]);

  const userOperation = {
    sender: smartAccount,
    nonce: 0n,
    callData,
    callGasLimit: 350_000n,
    verificationGasLimit: 150_000n,
    preVerificationGas: 50_000n,
    maxFeePerGas: ethers.parseUnits("20", "gwei"),
    maxPriorityFeePerGas: ethers.parseUnits("1", "gwei"),
    signature: "0x",
  };

  console.log("Smart sender:", userOperation.sender);
  console.log("EntryPoint:", ENTRY_POINT_V07);
  console.log("Batch calls:", targets.length);
  console.log("UserOp callData:", userOperation.callData);
  console.log("Дальше: подпись → eth_sendUserOperation → Bundler → EntryPoint");
}

// Подставьте свой ключ только в локальных тестах / testnet.
const demoKey =
  process.env.PRIVATE_KEY ??
  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";

await eoaTransfer(demoKey);

const smartAccount = process.env.SMART_ACCOUNT_ADDRESS;
if (smartAccount) {
  await smartAccountBatch(smartAccount);
} else {
  console.log("SMART_ACCOUNT_ADDRESS не задан — блок smart-аккаунта пропущен");
}

У EOA approve + swap — обычно две подписи и две TX. У smart-аккаунта оба шага укладываются в один UserOp через executeBatch (имя функции зависит от реализации кошелька: Safe, Kernel, Light Account и т.д.).


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

getCode(address) === "0x" — быстрый признак EOA. У smart-аккаунта байткод есть всегда (после деплоя).

EOA платит газ с баланса ключа: без ETH сеть не примет TX. Smart-аккаунт может списать депозит в EntryPoint или переложить оплату на Paymaster.

Восстановление у EOA = контроль seed/ключа. Потеряли ключ — адрес «мёртв» для вас. У smart-аккаунта политика восстановления — часть кода: опекуны, таймлок, смена owner — если провайдер/контракт это поддерживает.

Батчинг экономит не только клики: одна verification + один бандл вместо нескольких отдельных TX, хотя сама verification у ERC-4337 дороже простого EOA-transfer.


Что выбрать

Оставляйте EOA, если аудитория уже в MetaMask/Rabby, достаточно одной подписи ECDSA и вас устраивает «у пользователя есть ETH на газ».

Берите smart-аккаунт, если нужны gasless UX, approve+swap в одном подтверждении, social recovery / passkeys / session keys или онбординг без расширения — часто вместе с embedded-кошельком.

Safe «просто как multisig» без UserOp/EntryPoint — всё ещё зависит от того, что какой-то EOA шлёт обычные TX. Полный AA-flow — это ERC-4337 (или стек, который его прячет).


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

Считать любой контракт-кошелёк ERC-4337 → у адреса может быть bytecode (Gnosis Safe до AA), но без UserOperation / EntryPoint это не тот же путь, что в статье про ERC-4337.

Ждать, что smart-аккаунт всегда дешевле → простой перевод часто дороже из‑за verification. Выигрыш — в батчинге, спонсорстве газа и UX, не в цене одной transfer.

Путать владельца ключа и адрес аккаунта → ключ (или passkey) авторизует UserOp; sender в сети — адрес контракта. Балансы и allowance смотрите на sender, не на EOA-подписанта.


Что дальше

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