Vortex: Account Abstraction

Vortex: Account Abstraction

Project
ERC-7579
ERC-4337
5m

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:

AccountCore.sol
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 executes preCheck on all registered global hooks in a single pass before a transaction is processed.
  • _postCheckBatch: executes postCheck on all hooks with their corresponding context after the transaction completes.
AccountCore.sol
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:

  1. Extracts the selector and looks up the fallback handler module from the FALLBACKS_STORAGE_SLOT.
  2. If none is configured, but the selector matches one of the ERC token receiver interfaces (onERC721Received, onERC1155Received, or onERC1155BatchReceived), it returns the selector directly.
  3. 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, or DELEGATECALL).
    • Captures the return data and finally runs the postCheck hook if applicable.
AccountCore.sol
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:

You can explore the source code and tests here.


Explore more posts