
Vortex: Account Abstraction
Why Account Abstraction?
Ethereum's traditional Externally Owned Accounts (EOAs) have inherent limitations:
- Fixed key-pair control
- Inability to recover or rotate keys securely
- No native support for sponsored transactions (meta-transactions)
- Limited flexibility in access control or transaction behavior
To overcome these, the Ethereum community introduced ERC-4337, enabling account abstraction at the application layer. More recently, ERC-7579 emerged to standardize a modular architecture for smart accounts, making them more composable and upgradeable.
This project is a deep-dive implementation of a modular smart account protocol called Vortex that integrates both ERC-4337 and ERC-7579. It emphasizes extensibility, permission control, and composability.
Architecture Overview
Vortex is a modular smart account implementation compliant with ERC-4337 and ERC-7579. It provides a plug-and-play framework for validating operations, executing transactions, and managing fallback behavior.
The account is composed of independent modules for validation, execution, and runtime logic, designed to be upgradeable and adaptable to various use cases.
Execution Behavior
Vortex smart accounts support a modular execution pipeline defined by ERC-7579. The account delegates execution to installed modules, allowing for advanced behaviors such as conditional execution, batch processing, or permissioned calls. Each execution path starts with validation and may pass through optional pre- and post-execution hooks.
The execution flow is roughly as follows:
- Validation Phase: One or more validator modules verify the user operation (e.g. signature checks).
- Pre-Execution Hook (optional): A hook module may run pre-check logic or record metadata before execution.
- Execution Phase: The actual user call is routed to an executor module which performs the operation (e.g. token transfer, swap, etc).
- Post-Execution Hook (optional): A post-check hook may log, clean up state, or enforce invariants.
This modular pipeline enables building highly composable and secure smart account flows that are tailored to a wide range of use cases.
Core Components
Account Factories
Factories in this protocol are responsible for deploying and initializing the Vortex. They facilitate modular, deterministic, and secure deployment patterns, enabling tailored account creation flows depending on use case and governance needs.
MetaFactory enables controlled smart account deployment by restricting creation to a predefined set of authorized factories.
Factory Types
- AccountFactory: general-purpose factory to deploy standard smart accounts
- K1ValidatorFactory: specifically tailored to deploy accounts using the K1Validator
- RegistryFactory: deploys accounts with pre-authorized modules using a module registry
This structure ensures that account deployment can be tailored to specific security, governance, and user experience requirements across different domains and teams.
Modules
Modules are the building blocks of Vortex, categorized into the following types:
- Validator: a module used during the validation phase to determine if a transaction is valid and should be executed on the account.
- Executor: a module that can execute transactions on behalf of the smart account via a callback.
- Fallback Handler: a module that can extend the fallback functionality of the smart account.
- Hook: a module that can allow injection of pre and post execution logic, enabling features like access control, custom validation or state management.
Vortex supports installing multiple hooks globally across the account. Hooks can also be installed on a per-module basis. When invoked by the EntryPoint or by the account itself, Vortex executes batch checks across all global hooks. If the call originates from a specific module, it performs checks only on hooks associated with that module.
Below is a list of available modules included in this project.
- ECDSAValidator: validates user operations using standard ECDSA signatures.
- K1Validator: enables validation using custom K1-based signatures.
- Permit2Executor: leverages Uniswap’s Permit2 to handle ERC-20 token approvals through signature-based authorization
- UniversalExecutor: integrates Uniswap’s UniversalRouter to enable token swaps and complex DeFi operations within a single execution flow
- NativeWrapperFallback: adds support for handling native token wrapping and unwrapping for the smart account
- STETHWrapperFallback: adds support for handling stETH wrapping and unwrapping for the smart account
Module Factory
Modules can be deployed using the ModuleFactory, which abstracts the creation and registration of modules. Upon deployment, the ModuleFactory automatically registers the module to an ERC-7484-compatible registry.
Alternative Module Installation: Enable Module
In addition to calling installModule(uint256,address,bytes)
, modules can also be installed via _enableModule
, which can be invoked during the validateUserOp
lifecycle. This method eliminates the need for a separate transaction. When a module is enabled this way, the validateUserOp
function continues its validation after installing the module in a single call.
Implementation Highlights
Yul-Based Packing of PackedUserOperation
Vortex includes a highly optimized Yul implementation of validateUserOp
that manually encodes the PackedUserOperation
struct in memory before calling the validator. This reduces overhead and increases performance.
Below is a snippet showing how PackedUserOperation
is manually packed and validated:
1function _validateUserOp(
2 address validator,
3 PackedUserOperation memory userOp,
4 bytes32 userOpHash
5) internal virtual onlyValidator(validator) returns (ValidationData validationData) {
6 assembly ("memory-safe") {
7 // stores offsets for dynamic fields and computes the next position
8 function storeOffset(ptr, slot, offset) -> pos {
9 mstore(ptr, offset)
10 let length := and(add(mload(mload(slot)), 0x1f), not(0x1f))
11 pos := add(offset, add(length, 0x20))
12 }
13
14 // stores data for dynamic field and computes the next position of the pointer
15 function storeBytes(ptr, slot) -> pos {
16 let offset := mload(slot)
17 let length := mload(offset)
18 mstore(ptr, length)
19
20 offset := add(offset, 0x20)
21 let guard := add(offset, length)
22
23 for { pos := add(ptr, 0x20) } lt(offset, guard) { pos := add(pos, 0x20) offset := add(offset, 0x20) } {
24 mstore(pos, mload(offset))
25 }
26 }
27
28 let ptr := mload(0x40)
29
30 mstore(ptr, 0x9700320300000000000000000000000000000000000000000000000000000000) // validateUserOp(PackedUserOperation,bytes32)
31 mstore(add(ptr, 0x04), 0x40)
32 mstore(add(ptr, 0x24), userOpHash)
33 mstore(add(ptr, 0x44), shr(0x60, shl(0x60, mload(userOp)))) // sender
34 mstore(add(ptr, 0x64), mload(add(userOp, 0x20))) // nonce
35
36 let pos := 0x120 // where the offset of initCode is at
37 pos := storeOffset(add(ptr, 0x84), add(userOp, 0x40), pos) // initCode
38 pos := storeOffset(add(ptr, 0xa4), add(userOp, 0x60), pos) // callData
39
40 mstore(add(ptr, 0xc4), mload(add(userOp, 0x80))) // accountGasLimits
41 mstore(add(ptr, 0xe4), mload(add(userOp, 0xa0))) // preVerificationGas
42 mstore(add(ptr, 0x104), mload(add(userOp, 0xc0))) // gasFees
43
44 pos := storeOffset(add(ptr, 0x124), add(userOp, 0xe0), pos) // paymasterAndData
45 pos := storeOffset(add(ptr, 0x144), add(userOp, 0x100), pos) // signature
46
47 pos := add(ptr, 0x164)
48 pos := storeBytes(pos, add(userOp, 0x40)) // initCode
49 pos := storeBytes(pos, add(userOp, 0x60)) // callData
50 pos := storeBytes(pos, add(userOp, 0xe0)) // paymasterAndData
51 pos := storeBytes(pos, add(userOp, 0x100)) // signature
52
53 if iszero(call(gas(), validator, 0x00, ptr, sub(pos, ptr), 0x00, 0x20)) {
54 returndatacopy(ptr, 0x00, returndatasize())
55 revert(ptr, returndatasize())
56 }
57
58 validationData := mload(0x00)
59 }
60}
Batching Hook Execution
The _preCheckBatch
and _postCheckBatch
functions are optimized to batch hook invocations:
_preCheckBatch
: aggregates and executespreCheck
on all registered global hooks in a single pass before a transaction is processed._postCheckBatch
: executespostCheck
on all hooks with their corresponding context after the transaction completes.
1modifier withHook() virtual {
2 if (_isEntryPointOrSelf()) {
3 address[] memory hooks = _getHooks();
4 bytes[] memory contexts;
5
6 if (hooks.length != 0) contexts = _preCheckBatch(hooks, msg.sender, msg.value, msg.data);
7 _;
8 if (hooks.length != 0) _postCheckBatch(contexts);
9 } else {
10 address hook = _getHook(msg.sender);
11 bytes memory context;
12
13 if (hook != SENTINEL) context = _preCheck(hook, msg.sender, msg.value, msg.data);
14 _;
15 if (hook != SENTINEL) _postCheck(hook, context);
16 }
17}
18
19function _preCheckBatch(
20 address[] memory hooks,
21 address msgSender,
22 uint256 msgValue,
23 bytes calldata msgData
24) internal virtual returns (bytes[] memory contexts) {
25 assembly ("memory-safe") {
26 let ptr := mload(0x40)
27 let ptrSize := add(msgData.length, 0x84)
28 mstore(0x40, add(ptr, ptrSize))
29
30 mstore(ptr, 0xd68f602500000000000000000000000000000000000000000000000000000000) // preCheck(address,uint256,bytes)
31 mstore(add(ptr, 0x04), shr(0x60, shl(0x60, msgSender)))
32 mstore(add(ptr, 0x24), msgValue)
33 mstore(add(ptr, 0x44), 0x60)
34 mstore(add(ptr, 0x64), msgData.length)
35 calldatacopy(add(ptr, 0x84), msgData.offset, msgData.length)
36
37 contexts := mload(0x40)
38 let length := mload(hooks)
39 mstore(contexts, length)
40
41 let offset := add(add(contexts, 0x20), shl(0x05, length))
42 let hook
43
44 for { let i } lt(i, length) { i := add(i, 0x01) } {
45 hook := shr(0x60, shl(0x60, mload(add(add(hooks, 0x20), shl(0x05, i)))))
46
47 if iszero(call(gas(), hook, 0x00, ptr, ptrSize, codesize(), 0x00)) {
48 returndatacopy(ptr, 0x00, returndatasize())
49 revert(ptr, returndatasize())
50 }
51
52 // Store the current hook address along with the return data from the `preCheck` call.
53 // Equivalent to: `contexts[i] = abi.encode(hook, returndata);`
54 mstore(add(add(contexts, 0x20), shl(0x05, i)), offset)
55 mstore(offset, add(returndatasize(), 0x60))
56 mstore(add(offset, 0x20), hook)
57 mstore(add(offset, 0x40), 0x40)
58 mstore(add(offset, 0x60), returndatasize())
59 returndatacopy(add(offset, 0x80), 0x00, returndatasize())
60 offset := add(add(offset, 0x80), returndatasize())
61 }
62
63 mstore(0x40, offset)
64 }
65}
66
67function _postCheckBatch(bytes[] memory contexts) internal virtual {
68 assembly ("memory-safe") {
69 let ptr := mload(0x40)
70
71 mstore(ptr, 0x173bf7da00000000000000000000000000000000000000000000000000000000) // postCheck(bytes)
72
73 let length := mload(contexts)
74 let offset
75 let hook
76 let contextLength
77 let contextOffset
78 let guard
79
80 for { let i } lt(i, length) { i := add(i, 0x01) } {
81 offset := mload(add(add(contexts, 0x20), shl(0x05, i)))
82 hook := shr(0x60, shl(0x60, mload(add(offset, 0x20))))
83 contextLength := mload(add(offset, 0x60))
84 contextOffset := add(offset, 0x80)
85 guard := add(contextOffset, contextLength)
86
87 for { let pos := add(ptr, 0x04) } lt(contextOffset, guard) { pos := add(pos, 0x20) contextOffset := add(contextOffset, 0x20) } {
88 mstore(pos, mload(contextOffset))
89 }
90
91 mstore(0x40, and(add(guard, 0x1f), not(0x1f)))
92
93 if iszero(call(gas(), hook, 0x00, ptr, add(contextLength, 0x04), codesize(), 0x00)) {
94 returndatacopy(ptr, 0x00, returndatasize())
95 revert(ptr, returndatasize())
96 }
97 }
98 }
99}
100
This mechanism ensures modular extensibility while maintaining efficient execution.
Flexible Fallback Handling with Module Forwarding
The _fallback
function is the entry point for all fallback calls and routes them intelligently based on configuration:
- Extracts the selector and looks up the fallback handler module from the
FALLBACKS_STORAGE_SLOT
. - If none is configured, but the selector matches one of the ERC token receiver interfaces (
onERC721Received
,onERC1155Received
, oronERC1155BatchReceived
), it returns the selector directly. - If a fallback module is installed, Vortex:
- Checks for any associated hook and runs the
preCheck
hook before forwarding the call. - Forwards the call using the specified call type (
CALL
,STATICCALL
, orDELEGATECALL
). - Captures the return data and finally runs the
postCheck
hook if applicable.
- Checks for any associated hook and runs the
1function _fallback() internal virtual {
2 assembly ("memory-safe") {
3 function allocate(length) -> ptr {
4 ptr := mload(0x40)
5 mstore(0x40, add(ptr, length))
6 }
7
8 let selector := shr(0xe0, calldataload(0x00))
9
10 mstore(0x00, shl(0xe0, selector))
11 mstore(0x20, FALLBACKS_STORAGE_SLOT)
12
13 let configuration := sload(keccak256(0x00, 0x40))
14
15 if iszero(configuration) {
16 // If the selector is not explicitly configured but matches one of the following:
17 // 0x150b7a02: onERC721Received(address,address,uint256,bytes)
18 // 0xf23a6e61: onERC1155Received(address,address,uint256,uint256,bytes)
19 // 0xbc197c81: onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)
20 if or(eq(selector, 0x150b7a02), or(eq(selector, 0xf23a6e61), eq(selector, 0xbc197c81))) {
21 mstore(0x20, selector)
22 return(0x3c, 0x20)
23 }
24
25 mstore(0x00, 0xc2a825f5) // UnknownSelector(bytes4)
26 mstore(0x20, shl(0xe0, selector))
27 revert(0x1c, 0x24)
28 }
29
30 let callType := shr(0xf8, configuration)
31 let module := shr(0x60, shl(0x60, configuration))
32
33 mstore(0x00, module)
34 mstore(0x20, HOOKS_STORAGE_SLOT)
35
36 let hook := shr(0x60, shl(0x60, sload(keccak256(0x00, 0x40))))
37 let context
38
39 // The default state for a module-specific hook is set to SENTINEL: address(1)
40 // to indicate that no hook has been installed yet.
41 // Therefore, it will be reverted if the hook address is zero.
42 if iszero(hook) {
43 mstore(0x00, 0x026d9639) // ModuleNotInstalled(address)
44 mstore(0x20, module)
45 revert(0x1c, 0x24)
46 }
47
48 // Invoke `preCheck` on the hook if it exists and the call type is not CALLTYPE_STATIC: 0xFE.
49 if and(xor(hook, SENTINEL), xor(callType, 0xFE)) {
50 context := allocate(add(calldatasize(), 0x84))
51
52 mstore(context, 0xd68f602500000000000000000000000000000000000000000000000000000000) // preCheck(address,uint256,bytes)
53 mstore(add(context, 0x04), shr(0x60, shl(0x60, caller())))
54 mstore(add(context, 0x24), callvalue())
55 mstore(add(context, 0x44), 0x60)
56 mstore(add(context, 0x64), calldatasize())
57 calldatacopy(add(context, 0x84), 0x00, calldatasize())
58
59 if iszero(call(gas(), hook, 0x00, context, add(calldatasize(), 0x84), codesize(), 0x00)) {
60 returndatacopy(context, 0x00, returndatasize())
61 revert(context, returndatasize())
62 }
63
64 context := allocate(add(returndatasize(), 0x20))
65 mstore(context, returndatasize())
66 returndatacopy(add(context, 0x20), 0x00, returndatasize())
67 }
68
69 // Stores the call data and append `msg.sender` to the end.
70 let callData := allocate(calldatasize())
71 calldatacopy(callData, 0x00, calldatasize())
72 mstore(allocate(0x14), shl(0x60, caller()))
73
74 let success
75 switch callType
76 // CALLTYPE_SINGLE
77 case 0x00 {
78 success := call(gas(), module, callvalue(), callData, add(calldatasize(), 0x14), codesize(), 0x00)
79 }
80 // CALLTYPE_STATIC
81 case 0xFE {
82 success := staticcall(gas(), module, callData, add(calldatasize(), 0x14), codesize(), 0x00)
83 }
84 // CALLTYPE_DELEGATE
85 case 0xFF {
86 success := delegatecall(gas(), module, callData, add(calldatasize(), 0x14), codesize(), 0x00)
87 }
88 default {
89 mstore(0x00, 0xb96fcfe4) // UnsupportedCallType(bytes1)
90 mstore(0x20, shl(0xf8, callType))
91 revert(0x1c, 0x24)
92 }
93
94 if iszero(success) {
95 let ptr := mload(0x40)
96 returndatacopy(ptr, 0x00, returndatasize())
97 revert(ptr, returndatasize())
98 }
99
100 // Stores the return data from the fallback module.
101 let returnData := allocate(add(returndatasize(), 0x20))
102 mstore(returnData, returndatasize())
103 returndatacopy(add(returnData, 0x20), 0x00, returndatasize())
104
105 // Invoke `postCheck` on the hook if it exists and the call type is not CALLTYPE_STATIC: 0xFE.
106 if and(xor(hook, SENTINEL), xor(callType, 0xFE)) {
107 let offset := add(context, 0x20)
108 let length := mload(context)
109 let guard := add(offset, length)
110
111 context := allocate(add(length, 0x44))
112
113 mstore(context, 0x173bf7da00000000000000000000000000000000000000000000000000000000) // postCheck(bytes)
114 mstore(add(context, 0x04), 0x20)
115 mstore(add(context, 0x24), length)
116
117 // prettier-ignore
118 for { let pos := add(context, 0x44) } lt(offset, guard) { pos := add(pos, 0x20) offset := add(offset, 0x20) } {
119 mstore(pos, mload(offset))
120 }
121
122 mstore(0x40, and(add(guard, 0x1f), not(0x1f)))
123
124 if iszero(call(gas(), hook, 0x00, context, add(length, 0x44), codesize(), 0x00)) {
125 returndatacopy(context, 0x00, returndatasize())
126 revert(context, returndatasize())
127 }
128 }
129
130 return(add(returnData, 0x20), add(mload(returnData), 0x20))
131 }
132}
Deployments
You can explore the deployment information here.
References
The following repositories served as key references during the development of this project:
- ERC-7579 / ERC-7579 Implementation
- ZeroDev / Kernel
- Biconomy / Nexus
- rhinestone / ModuleKit
- Vectorized / Solady
You can explore the source code and tests here.