Indexing ERC4337 (Account Abstraction)

Blockflow
5 min readFeb 21, 2024

--

This guide will focus on indexing ERC4337 (Account abstraction) using the blockflow console.

If you’re new to this guide do check — A Beginner Guide to Indexing ERC20 transfers.

Before we dive deep into the know-how for indexing ERC4337, it’s important to get an in-depth understanding of Account Abstraction.

Now coming to the topic of indexing ERC4337 userOperations, first, we’ll need to understand what exactly we are trying to index.

Account Abstraction user operation is a new transaction type. UserOperations allow users to define a series of steps that should be executed as part of a single operation.

But indexing userOperation end to end will need data from multiple places, some data is stored in transaction functionCall and some is emitted as an event.

  • Event: UserOperationEvent(bytes32 userOpHash, address sender, address paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed)
  • Function: handleOps(tuple[] ops, address beneficiary)

Let’s start by creating a database in the blockflow console that will store userOperation of account abstraction.

{
// will get these from event data
id: String; // userOp hash
txHash: String;
block: String;
bundler: String;
sender: String;
paymaster: String;
nonce: Number;
success: Boolean;
actualGasCost: Number;
actualGasUsed: Number;
createdAt: String;

// Will get all these from function calldata
initCode: string;
callData: string;
callGasLimit: string;
verificationGasLimit: string;
preVerificationGas: string;
maxFeePerGas: string;
maxPriorityFeePerGas: string;
paymasterAndData: string;
signature: string;
beneficiary: string;
}

Let’s name this database as UserOperation!

Once we’re done with creating a database, the next step would be to write instance logic. As two triggers provide this data, we’ll need to create an instance with two trigger types:

  • contract event trigger.
  • function call trigger.

Contract Event Trigger

This type triggers whenever a particular event is emitted by a contract in inspection. In this tutorial perspective, it will be triggered whenever UserOperationEvent executes on the entry point contract.

/**
* @dev Event::UserOperationEvent(bytes32 userOpHash, address sender, address paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed)
* @param context trigger object with contains {event: {userOpHash ,sender ,paymaster ,nonce ,success ,actualGasCost ,actualGasUsed }, transaction, block, log}
* @param bind init function for database wrapper methods
*/
export const handler = async (
context: IEventContext,
bind: IBind,
secrets: any
) => {
const { event, transaction, block, log } = context;
let {
userOpHash,
sender,
paymaster,
nonce,
success,
actualGasCost,
actualGasUsed,
} = event;

nonce = nonce.toString();
sender = sender.toString();
paymaster = paymaster.toString();
userOpHash = userOpHash.toString();
actualGasCost = actualGasCost.toString();
actualGasUsed = actualGasUsed.toString();
}

Now as we are already familiar with concepts of connecting to a database (check). We’ll be connecting the UserOperation database.

// code continued ...
const userOpDB = bind(UserOperation);

// making userOpHash an index parameter i.e. id
let userOp = await userOpDB.findOne({ id: userOpHash.toLowerCase()});

// if this userOp doesn't exists in database we need to create its entry
userOp ??= await userOpDB.create({ id: userOpHash.toLowerCase() });

Now, we are done with the connection, and loaded/created our userOp object.

Let’s quickly fill in the rest of the logic.

export const handler = async (
context: IEventContext,
bind: IBind,
secrets: any
) => {
const { event, transaction, block, log } = context;
let {
userOpHash,
sender,
paymaster,
nonce,
success,
actualGasCost,
actualGasUsed,
} = event;

nonce = nonce.toString();
sender = sender.toString();
paymaster = paymaster.toString();
userOpHash = userOpHash.toString();
actualGasCost = actualGasCost.toString();
actualGasUsed = actualGasUsed.toString();

const userOpDB = bind(UserOperation);

// making userOpHash an index parameter i.e. id
let userOp = await userOpDB.findOne({ id: userOpHash.toLowerCase()});

// if this userOp doesn't exists in database we need to create its entry
userOp ??= await userOpDB.create({ id: userOpHash.toLowerCase() });

userOp.bundler = transaction.transaction_from_address.toLowerCase();
userOp.paymaster = paymaster.toLowerCase();
userOp.sender = sender;
userOp.nonce = Number(nonce);
userOp.success = success;
userOp.actualGasCost = Number(actualGasCost);
userOp.actualGasUsed = Number(actualGasUsed);

userOp.txHash = transaction.transaction_hash;
userOp.createdAt = block.block_timestamp;

// Now last things is to save this data into the database again.
await userOpDB.save(userOp);
}

we’re done with the first half of the indexing, now let’s move on to the next part i.e. function call indexing.

function call trigger

This type triggers when a transaction to contract occurs, and you want to index input parameters of that transaction calldata. In this tutorial perspective, it will be triggered whenever the userOperation transaction is sent to the entry point contract.

Before we go writing a handler for this one, we need to calculate the userOpHash off-chain in our logics. But what’s a userOpHash?

The userOpHash in ERC-4337 tx is a hash over the userOp (except signature), entryPoint, and chainId. It is used to validate the caller as a trusted EntryPoint and to ensure that the user operation is valid based on the userOpHash. In layman’s terms, user op is similar to transaction hash.

To calculate userOpHash off-chain, we will be using this function.

import { keccak256 } from "@ethersproject/keccak256";
import { defaultAbiCoder } from "@ethersproject/abi";

function pack(userOp: any) {
const sender = userOp.sender;
const nonce = userOp.nonce;
const hashInitCode = keccak256(userOp.initCode);
const hashCallData = keccak256(userOp.callData);
const callGasLimit = userOp.callGasLimit;
const verificationGasLimit = userOp.verificationGasLimit;
const preVerificationGas = userOp.preVerificationGas;
const maxFeePerGas = userOp.maxFeePerGas;
const maxPriorityFeePerGas = userOp.maxPriorityFeePerGas;
const hashPaymasterAndData = keccak256(userOp.paymasterAndData);

const types = [
"address",
"uint256",
"bytes32",
"bytes32",
"uint256",
"uint256",
"uint256",
"uint256",
"uint256",
"bytes32",
];
const values = [
sender,
nonce,
hashInitCode,
hashCallData,
callGasLimit,
verificationGasLimit,
preVerificationGas,
maxFeePerGas,
maxPriorityFeePerGas,
hashPaymasterAndData,
];

return defaultAbiCoder.encode(types, values);
}

function getUserOpHash(userOp: any, entryPointAddr: string, chainId: string) {
// Hash the packed data
const hashedUserOp = keccak256(pack(userOp));

// Correct the types to match the values being encoded
const types = ["bytes32", "address", "uint256"];
const values = [hashedUserOp, entryPointAddr, chainId];

// Encode and hash again
const encoded = defaultAbiCoder.encode(types, values);
return keccak256(encoded);
}

Now, we have everything ready to index userOp final parameters. We’ll reconnect with the UserOperation database in function call handler, and update it with important values.

const CHAIN_ID = "1"; // ethereum
/**
* @dev Function::handleOps(tuple[] ops, address beneficiary)
* @param context trigger object with contains {function: {ops ,beneficiary }, transaction, block, log}
* @param bind init function for database wrapper methods
*/
export const handler = async (context: IFunctionContext, bind: IBind, secrets: any) => {
// Implement your function handler logic for handleOps here

const { functionParams, transaction, block } = context;
let { ops, beneficiary } = functionParams;

beneficiary = beneficiary.toString();

const entryPoint = transaction.transaction_to_address;
}

Since handleOps function takes an array of userOp tuples, we’ll need to loop over each entry, calculate its userOp hash and update entries in the database.

const CHAIN_ID = "1";
export const handler = async (context: IFunctionContext, bind: Function, secrets: any) => {
const { functionParams, transaction, block } = context;
let { ops, beneficiary } = functionParams;

beneficiary = beneficiary.toString();

const entryPoint = transaction.transaction_to_address;

for (const op of ops) {
const userOpHash = getUserOpHash(op, entryPoint, CHAIN_ID);

const userOpDB = bind(UserOperation);

// since userOpHash is unique id, we'll be updating the same document
// that we updated in event based trigger above.
let userOp = await userOpDB.findOne({ id: userOpHash.toLowerCase() });
userOp ??= await userOpDB.create({ id: userOpHash.toLowerCase() });

userOp.sender = op.sender;
userOp.nonce = op.nonce;
userOp.initCode = op.initCode;
userOp.callData = op.callData;
userOp.callGasLimit = op.callGasLimitring;
userOp.verificationGasLimit = op.verificationGasLimit;
userOp.preVerificationGas = op.preVerificationGas;
userOp.maxFeePerGas = op.maxFeePerGas;
userOp.maxPriorityFeePerGas = op.maxPriorityFeePerGas;
userOp.paymasterAndData = op.paymasterAndData;
userOp.signature = op.signature;
userOp.beneficiary = beneficiary;

// save each userOp entry into the database.
await userOpDB.save(userOp);
}
}

And that’s it! In a few lines of code, we’ve indexed userOperations. But that’s just the tip of the iceberg in indexing ERC4337, there are more folks in the picture than userOp. There exist bundlers, paymasters, and especially user accounts.

But indexing those is a topic for another guide. See ya!

--

--

Blockflow

The smarter way for crypto teams to build products. Managed Databases, On-chain Triggers, and Custom APIs in one product. https://blockflow.network/