Деконструкція ERC-4337: Некастодіальна абстракція акаунтів без WaaS
Технологія Account Abstraction (ERC-4337) кардинально покращує користувацький досвід Web3, усуваючи необхідність зберігати сид-фрази, підтримуючи спонсорство газу (Paymaster) та пакетні транзакції (Batched Calls).
Проте більшість розробників потрапляють у пастку комерційних постачальників «Wallet-as-a-Service» (Privy, Dynamic, Biconomy Cloud). Цей посібник демонструє, як побудувати повністю некастодіальну архітектуру смарт-акаунтів на базі відкритих компонентів.
1. Спектр абстракції акаунтів: підписувач проти акаунта
Фундаментальний принцип ERC-4337 — суворе відокремлення Підписувача (Signer) від Смарт-акаунта (Smart Account):
┌────────────────────────────────────────────────────────────────────────┐│ Decoupled Account Abstraction Model │├───────────────────────────────────┬────────────────────────────────────┤│ Signer (Cryptographic Authority) │ Smart Account (On-Chain Execution) │├───────────────────────────────────┼────────────────────────────────────┤│ • Local Secp256k1 EOA Key │ • Solady ERC-4337 (Single Owner) ││ • Hardware Wallet (Ledger/Trezor) │ • ERC-7579 Modular Account (Kernel)││ • SSS / MPC Key Share (Non-cust.) │ • Atomic Multi-Call Batching ││ • WebAuthn P-256 (via Verifier) │ • Canonical EntryPoint v0.7 ││ • Sole Job: Sign arbitrary bytes │ • Asset Custody & Nonce Validation │└───────────────────────────────────┴────────────────────────────────────┘Деконструкція компромісів WaaS
Підписувач лише генерує криптографічні підписи, тоді як ончейн-акаунт володіє активами та валідує правила. Це означає, що підписувач може бути замінений або експортований без блокування доступу до коштів користувача.
2. Двофазний життєвий цикл: userOpHash проти txHash
На відміну від звичайних EOA-транзакцій, виконання операції смарт-акаунта проходить через альтернативний мемпул бандлерів:
Дилема подвійного ідентифікатора
- userOpHash (Intent ID): keccak256-хеш операції PackedUserOperation. Існує в пам'яті бандлерів і відстежується через
eth_getUserOperationReceipt. - txHash (On-Chain Settlement ID): хеш фінальної транзакції бандлера в блокчейні, яка викликає
EntryPoint.handleOps()і може містити операції кількох різних користувачів.
Три збої наївних фронтендів у продакшні
1. Опитування RPC проти мемпулу бандлера
Спроба опитувати звичайний RPC-вузол через userOpHash призводить до помилок, оскільки мемпул бандлера ізольований від звичайного мемпулу ноди.
2. Обфускація calldata у бандлі
Агрегація декількох операцій в один виклик handleOps ховає вихідні виклики та ускладнює відновлення стану окремого користувача.
3. Приховані помилки виконання всередині EntryPoint.handleOps
Транзакція бандлера вважається успішною в мережі (status 1), навіть коли внутрішня операція користувача зазнала невдачі (UserOperationRevertReason).
3. Архітектури смарт-акаунтів: Solady проти ERC-7579
┌────────────────────────────────────────────────────────────────────────┐│ Smart Account Architectural Matrix │├───────────────────────────────────┬────────────────────────────────────┤│ Solady ERC4337.sol (Minimalist) │ ERC-7579 (Modular Standard) │├───────────────────────────────────┼────────────────────────────────────┤│ • Pure Yul Assembly implementation│ • Modular Proxy (Kernel v3, Safe) ││ • Lowest possible deployment gas │ • Dynamic Module Installation ││ • Minimal runtime overhead │ • Native Session Keys & Subscriptions││ • Strict Single-Owner (secp256k1) │ • Multi-Validator (WebAuthn, ECDSA)││ • Immutably fixed logic │ • Social Recovery & Spending Limits│└───────────────────────────────────┴────────────────────────────────────┘Криптографічна реальність Solady та Passkeys
Solady ERC4337 оптимізований для EOA власників (secp256k1) з мінімальними витратами газу на розгортання. Для повноцінної підтримки біометричних ключів Passkeys (P-256) рекомендується використовувати модульні акаунти ERC-7579.
4. Реалізація: канонічний Viem AA проти TUWA
4.1 Варіант A: Канонічний Viem (viem/account-abstraction)
// src/services/canonicalSmartAccount.tsimport { http, createPublicClient, encodeFunctionData, parseUnits, erc20Abi, pad, hexToBigInt, type Hex } from 'viem';import { sepolia } from 'viem/chains';import { createBundlerClient, createPaymasterClient, toSoladySmartAccount, type BundlerClient, type ToSoladySmartAccountReturnType,} from 'viem/account-abstraction';import { toAccount } from 'viem/accounts';import type { WalletClient, HttpTransport } from 'viem'; /** * Initializes a Solady smart account and binds a canonical Viem Bundler client * with native Pimlico paymaster sponsorship. */export async function initializeCanonicalAccount(walletClient: WalletClient) { const apiKey = process.env.NEXT_PUBLIC_PIMLICO_API_KEY; if (!apiKey) throw new Error('Missing NEXT_PUBLIC_PIMLICO_API_KEY'); const bundlerRpcUrl = `https://api.pimlico.io/v2/sepolia/rpc?apikey=${apiKey}`; const publicClient = createPublicClient({ chain: sepolia, transport: http(), }); if (!walletClient.account) { throw new Error('WalletClient must have an active account.'); } const ownerAccount = walletClient.account; // 1. Wrap the JSON-RPC account to ensure signing delegation methods are available const owner = toAccount({ address: ownerAccount.address, async signMessage({ message }) { return walletClient.signMessage({ account: ownerAccount, message }); }, async signTransaction(tx) { return walletClient.signTransaction({ account: ownerAccount, ...tx } as any); }, async signTypedData(typedData) { return walletClient.signTypedData({ account: ownerAccount, ...typedData } as any); }, }); // 2. Compute Solady factory-compliant deterministic salt: // Solady's ERC4337Factory packs the owner address directly into the top 160 bits of ownSalt (extracted via shr(96, ownSalt)). const salt = pad(ownerAccount.address, { dir: 'right', size: 32 }); // 3. Create Solady Smart Account with explicit owner delegation and salt const account = await toSoladySmartAccount({ client: publicClient, owner, salt, }); // 4. Instantiate Viem Paymaster Client configured for Pimlico const paymasterClient = createPaymasterClient({ transport: http(bundlerRpcUrl), }); // 5. Instantiate canonical Viem Bundler Client with paymaster sponsorship const bundlerClient = createBundlerClient({ account, client: publicClient, transport: http(bundlerRpcUrl), paymaster: paymasterClient, paymasterContext: { sponsorshipPolicyId: 'sp_my_policy_id', // ERC-7677 / Pimlico sponsorship policy }, // Pimlico refuses UserOps priced at the chain's fees ("maxFeePerGas must be at least …"): price them at its own // gas price userOperation: { estimateFeesPerGas: async ({ bundlerClient }) => { const { fast } = (await bundlerClient.request({ method: 'pimlico_getUserOperationGasPrice' } as never)) as { fast: { maxFeePerGas: Hex; maxPriorityFeePerGas: Hex }; }; return { maxFeePerGas: hexToBigInt(fast.maxFeePerGas), maxPriorityFeePerGas: hexToBigInt(fast.maxPriorityFeePerGas), }; }, }, }); return { account, bundlerClient, publicClient, paymasterClient };} /** * Dispatches a UserOp, tracks the mempool stage, and verifies internal execution success. */export async function executeCanonicalTransfer({ bundlerClient, account, tokenAddress, recipient, amount,}: { bundlerClient: BundlerClient<HttpTransport>; account: ToSoladySmartAccountReturnType; tokenAddress: `0x${string}`; recipient: `0x${string}`; amount: string;}) { const callData = encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [recipient, parseUnits(amount, 18)], }); // 1. Dispatch UserOperation to Pimlico Bundler const userOpHash = await bundlerClient.sendUserOperation({ account, calls: [{ to: tokenAddress, value: 0n, data: callData }], }); // 2. Poll eth_getUserOperationReceipt on Bundler RPC (Stage 1 tracking) const userOpReceipt = await bundlerClient.waitForUserOperationReceipt({ hash: userOpHash, pollingInterval: 1_500, timeout: 60_000, }); // 3. Prevent silent execution revert: verify inner success flag if (!userOpReceipt.success) { const reason = userOpReceipt.reason || 'UserOperation reverted inside EntryPoint.handleOps'; throw new Error(`Execution reverted: ${reason}`); } // 4. Capture canonical settlement hash for Stage 2 const settledTxHash = userOpReceipt.receipt.transactionHash; const explorerUrl = `https://sepolia.etherscan.io/tx/${settledTxHash}`; return { userOpHash, settledTxHash, explorerUrl };}4.2 Варіант B: TUWA Stack (@tuwaio/orbit-evm та @tuwaio/pulsar-evm)
┌────────────────────────────────────────────────────────────────────────┐│ The Open-Source TUWA Stack │├────────────────────────────────────────────────────────────────────────┤│ • @tuwaio/orbit-evm : Bundler/Paymaster caching, Solady salt padding ││ • @tuwaio/pulsar-core : Headless, framework-agnostic transaction FSM ││ • @tuwaio/pulsar-evm : Native Two-Stage tracker (Mempool ➔ Settlement)││ • @tuwaio/nova-uikit : Unstyled / Tailwind presentation layer │└────────────────────────────────────────────────────────────────────────┘Крок 1: Оркестрація смарт-акаунта за допомогою Orbit EVM
Хук або утиліта створює клієнт смарт-акаунта на базі стандарту ERC-4337 з автоматичним спонсоруванням газу через Paymaster:
// src/services/orbitSmartAccount.tsimport { createPimlicoSmartAccountClient } from '@tuwaio/orbit-evm';import { sepolia } from 'viem/chains';import type { Config } from '@wagmi/core'; export async function getOrbitSmartAccountClient(wagmiConfig: Config) { // Resolves walletClient from Wagmi, attaches cached Pimlico Paymaster, // and configures EntryPoint v0.7 compliant Bundler client const { account, bundlerClient, publicClient, paymasterClient } = await createPimlicoSmartAccountClient({ chain: sepolia, wagmiConfig, apiKey: process.env.NEXT_PUBLIC_PIMLICO_API_KEY, sponsor: true, }); return { account, bundlerClient, publicClient, paymasterClient };}Крок 2: Реактивне двофазне відстеження з Pulsar
У реальному інтерфейсі критично важливо розділяти чисту транзакційну дію та диспетчеризацію реактивного UI-компонента:
Частина A: Чиста транзакційна дія (incrementPimlico.ts)
Функція залишається повністю безголовою (headless) та відв'язаною від стану інтерфейсу. Вона ініціалізує смарт-клієнт через Orbit, кодує calldata і відправляє UserOperation у бандлер:
// src/transactions/evm/incrementPimlico.tsimport { createPimlicoSmartAccountClient } from '@tuwaio/orbit-evm';import { type Config } from '@wagmi/core';import { encodeFunctionData, type Hex } from 'viem';import { sepolia } from 'viem/chains'; import { CounterAbi } from '@/abis/CounterAbi';import { COUNTER_ADDRESS } from '@/constants'; /** * Executes a counter increment via an ERC-4337 UserOperation using a Pimlico bundler. * Returns the raw userOpHash for lifecycle tracking. */export async function incrementPimlico({ wagmiConfig }: { wagmiConfig?: Config }): Promise<Hex | undefined> { if (!wagmiConfig) return undefined; // 1. Initialize Solady Smart Account & Bundler client via Orbit EVM const { account, bundlerClient } = await createPimlicoSmartAccountClient({ chain: sepolia, wagmiConfig, apiKey: process.env.NEXT_PUBLIC_PIMLICO_API_KEY, rpcUrl: process.env.NEXT_PUBLIC_ALCHEMY_KEY ? `https://eth-sepolia.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}` : undefined, }); // 2. Encode contract calldata const data = encodeFunctionData({ abi: CounterAbi, functionName: 'increment', }); // 3. Dispatch UserOperation to alternative mempool and return userOpHash return bundlerClient.sendUserOperation({ account, calls: [ { to: COUNTER_ADDRESS, data, value: 0n, }, ], });}Частина B: Диспетчеризація реактивного UI-компонента (CounterButton.tsx)
У шарі React компонент підключається до сховища Pulsar через селектор хука (usePulsarStore((state) => state.executeTxAction)). Він викликає дію з метаданими життєвого циклу, автоматичним трекером (TransactionTracker.ERC4337) та 4-фазними сповіщеннями:
// src/components/evm/CounterButton.tsx'use client'; import { OrbitAdapter } from '@tuwaio/orbit-core';import { TransactionTracker } from '@tuwaio/pulsar-core';import { useConfig } from 'wagmi';import { sepolia } from 'viem/chains'; import { usePulsarStore } from '@/hooks/pulsarStoreHook';import { incrementPimlico } from '@/transactions/evm/incrementPimlico';import { COUNTER_ADDRESS } from '@/constants'; export const CounterButton = () => { const wagmiConfig = useConfig(); // Select the action dispatcher from the centralized Pulsar store const executeTxAction = usePulsarStore((state) => state.executeTxAction); const handleIncrement = async () => { await executeTxAction({ actionFunction: () => incrementPimlico({ wagmiConfig }), onSuccess: (tx) => { console.log('ERC-4337 UserOperation confirmed on-chain:', tx); }, params: { type: 'INCREMENT_PIMLICO', adapter: OrbitAdapter.EVM, tracker: TransactionTracker.ERC4337, desiredChainID: sepolia.id, pimlicoApiKey: process.env.NEXT_PUBLIC_PIMLICO_API_KEY, title: [ 'Incrementing (ERC-4337)', 'Incremented (ERC-4337)', 'Error when increment (ERC-4337)', 'Increment tx replaced', ], description: [ 'Dispatching sponsored UserOperation to Pimlico bundler...', 'Counter successfully incremented on-chain!', 'UserOperation reverted or failed during execution.', 'UserOperation was replaced with higher gas fees.', ], payload: { contractAddress: COUNTER_ADDRESS, }, withTrackedModal: true, }, }); }; return ( <button onClick={handleIncrement} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg shadow-md transition-colors" > Sponsored Increment (+1) </button> );};Як двоетапний пайплайн Pulsar працює під капотом
Пайплайн слухає мемпул бандлера на першому етапі (eth_getUserOperationReceipt), перевіряє внутрішній receipt.success і на другому етапі передає зовнішній txHash у стандартний EVM-трекер для очікування фінальності блоку.
5. Крос-девайсна персистентність: власний індексатор чи Quasar
Постійною проблемою фронтенду є дилема закриття вкладки: користувачі часто закривають вкладку браузера одразу після підтвердження транзакції в гаманці, що перериває роботу JS-полінгу. Інженерні команди мають два шляхи для вирішення персистентності:
┌────────────────────────────────────────────────────────────────────────┐│ Cross-Device Architecture Options │├───────────────────────────────────┬────────────────────────────────────┤│ Option A: Build Your Own Indexer │ Option B: Quasar Engine │├───────────────────────────────────┼────────────────────────────────────┤│ • Ponder or Envio event indexer │ • Cloud: managed, usage-based ││ • Custom PostgreSQL schema │ • Community Edition: self-hosted ││ • Your own sync API & webhooks │ • Same @tuwaio/quasar-sdk for both ││ • Full control of data model │ • Two-phase EVM & Solana tracking ││ • You build and operate it all │ • HMAC-signed webhook delivery │└───────────────────────────────────┴────────────────────────────────────┘Варіант A: Власний індексатор (Ponder / Envio)
Команди, яким потрібна повністю власна модель даних, можуть розгорнути легкий відкритий індексатор (Ponder або Envio), що прослуховує канонічний контракт EntryPoint v0.7:
event UserOperationEvent( bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed);Варіант B: Некастодіальна синхронізація з Quasar
Для команд, які віддають перевагу готовому рішенню, Quasar пропонує некастодіальний движок синхронізації та вебхуків. Дві редакції (Quasar Cloud та self-hosted Quasar Community Edition) працюють на базі однакового API:
// src/app/actions/syncTransaction.ts'use server'; import { Quasar, type Transaction } from '@tuwaio/quasar-sdk';import { getSiwxServerSession, isSessionMatchingTarget } from '@tuwaio/siwx-server';import { cookies } from 'next/headers';import { createPublicClient, http } from 'viem';import { sepolia } from 'viem/chains'; const quasar = new Quasar({ // Quasar Cloud by default; set to your Community Edition node, e.g. https://api.your-domain.com baseUrl: process.env.NEXT_PUBLIC_QUASAR_BASE_URL || 'https://api.tuwa.io', secretKey: process.env.QUASAR_SECRET_KEY ?? '',}); /** * Syncs an ERC-4337 UserOperation transaction to Quasar for cross-device persistence and webhooks. * Authenticates the caller via SIWX session before anything reaches Quasar. * Accurately validates the cryptographic relationship between EOA Signer and Smart Account Sender. */export async function syncTransaction(tx: Transaction) { const session = await getSiwxServerSession({ cookieSource: await cookies(), cookieName: 'siwx-session', }); if (!session?.address) { return { success: false, reason: 'unauthenticated' }; } // Authorization Check: // 1. Direct EOA match (Standard transactions where tx.from is the signing wallet) const isDirectMatch = isSessionMatchingTarget(session, tx.from, tx.chainId); // 2. Smart Account validation (ERC-4337): // In ERC-4337, tx.from is the Smart Account contract address, while session.address is the EOA Signer. // Validate that the authenticated session signer is the verified on-chain owner of tx.from: let isAuthorized = isDirectMatch; if (!isAuthorized && tx.from) { const publicClient = createPublicClient({ chain: sepolia, transport: http(), }); try { // Query the owner() view function on the Solady smart account contract const accountOwner = await publicClient.readContract({ address: tx.from as `0x${string}`, abi: [ { type: 'function', name: 'owner', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view', }, ], functionName: 'owner', }); if (accountOwner.toLowerCase() === session.address.toLowerCase()) { isAuthorized = true; } } catch { // If the account is counterfactual (not yet deployed on-chain), // the server verifies that the deterministic salt embeds session.address. isAuthorized = false; } } if (!isAuthorized) { return { success: false, reason: 'unauthorized_account_signer' }; } try { // Registers the complete transaction state including UI payload with Quasar await quasar.pulsar.syncCreate(tx, 'MyDApp'); return { success: true }; } catch (error) { console.error('[Quasar Sync Error]', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; }}6. Порівняльна архітектурна матриця
| Параметр | Класичний EOA (Viem / Wagmi) | Вбудований WaaS (Privy / Dynamic) | Канонічний Open Source (Viem AA + ERC-7579 / Solady) | Модульний стек TUWA (Orbit + Pulsar + опц. Quasar) |
|---|---|---|---|---|
| Архітектура та рівні | Стандартний низькорівневий JSON-RPC клієнт | Монолітний SDK вендора та закритий релеєр у хмарі | Базові протокольні примітиви (ручне кешування на клієнті) | Ергономічні клієнтські рівні (orbit + pulsar) |
| Володіння ключами | 100% користувач (Апаратний / Браузерний EOA) | Некастодіальний 2-of-3 SSS у TEE анклавах вендора | 100% користувач (Апаратний / Local / Passkey з модулем) | 100% користувач (Апаратний / Локальний / EOA) |
| Примітиви виконання | Одиничний виклик на підпис | Закритий релеєр або стандартний AA | Нативний пакетний виклик (Multi-Call) ERC-4337 v0.7 | Нативний пакетний виклик (Multi-Call) ERC-4337 v0.7 |
| Політика газу (Paymaster) | Тільки нативний ETH/MATIC (платить користувач) | Керовані правила спонсорування вендора | Відкритий Paymaster ERC-4337 (Pimlico, Biconomy тощо) | Плагінний Paymaster через @tuwaio/orbit-evm |
| Архітектура смарт-акаунта | Відсутня (стандартний акаунт) | Закриті контракти або модульний проксі | Вибір: Assembly Solady або модульний ERC-7579 | Solady Single-Owner через @tuwaio/orbit-evm |
| Підтримка Passkey | Не підтримується | Власна біометрична автентифікація вендора | Нативна на ERC-7579 / EIP-7212 (потребує модуля) | Потребує зовнішнього верифікатора Passkey / модуля |
| Модель стану транзакцій | Базовий полінг txHash у пам'яті | Закриті хмарні вебхуки вендора | Viem waitForUserOperationReceipt (власний FSM) | Детермінований двоетапний автомат (userOp ➔ txHash) |
| Відстеження мемпулу та Explorer | Н/Д (стандартний мемпул ноди) | Закрите модальне вікно статусу | Мемпул: Bundler RPC / Фіналізовано: Block Explorer | Двоетапно: статус бандлера ➔ канонічний Explorer |
| Крос-девайсна персистентність | Втрачається при закритті вкладки | Хмарна синхронізація акаунта вендора | Власний відкритий індексатор (Ponder / Envio + БД) | Local-first стан + опціональний Quasar (Cloud або self-hosted) |
| Хостинг бекенду | Н/Д (тільки клієнт) | Тільки хостинг вендора | Власна розробка та експлуатація | Quasar Cloud або self-hosted Community Edition |
| Ліцензія та розширюваність | Open Source (MIT) | Закритий код SDK та корпоративна підписка | 100% Open Source (MIT / Apache-2.0) | Apache-2.0 SDK та self-hostable Quasar; керований Quasar Cloud опціонально |
Підсумок: проєктування некастодіальних смарт-акаунтів
Справжній суверенітет у Web3 досягається розділенням ключів та контрактної логіки, використанням відкритих стандартів бандлерів і персистентного стану транзакцій.
📚 Наступні кроки
- Документація Orbit EVM : хелпери EntryPoint v0.7 та Solady.
- Full-Stack React застосунок: інтеграція з UI Nova.