Indexing ERC20

Blockflow
6 min readFeb 21, 2024

--

This guide will focus on indexing ERC20 token transfers using the blockflow console.

What is Blockflow?

Blockflow is a serverless backend infra provider for web3, that helps you build trigger-based backends with ease.

Now coming to the topic of indexing ERC20 transfer, first, we’ll need to understand what exactly we are trying to index. We wanted a database that stores the balance information of the user.

ERC20 standard contract emits an event named: “Transfer(address,address,uint256)” that is emitted when a transfer happens between any two addresses. We will be indexing this event.

As blockflow supports MongoDB as a managed database, firstly we need to create a database. The database should have the following schema for indexing ERC20. Let's name this database Balances.

{
id: "String",
address: "String",
interactedAddresses: "Array",
tradeVolume: "String",
currentBalance: "String",
allTimeHigh: "String",
transactionHash: "String",
}

Now what does each field mean?

  • id: This is an indexed parameter in the managed database and a required parameter, that should be used in a way to minimise efforts to find specific documents from mongoDB (ex. make id as user address)
  • address: Current user address, can be from or to the address of the event.
  • interactedAddress: It’s an array that stores all the addresses interacted with by a particular user.
  • tradeVolume: This stores the all-time trade volume of that address.
  • currentBalance: Current balance of the token of that address.
  • allTimeHigh: This is the maximum holding of that token by that particular address. It can be greater or equal to currentBalance.
  • transactionHash: The transaction hash of the current transfer.

Now we have the database defined, let’s move on to the compute part Instances.

What are Instances?

Instances are compute or lambda functions, that trigger whenever a particular condition is met. Condition can be any blockchain trigger, let it be an event, function call, or slot change.

Instances are also the gateway to your database, based on the logic defined there your database will fill up.

An instance function looks like —

/**
* @dev Event::Transfer(address from, address to, uint256 value)
* @param context trigger object with contains {event: {from ,to ,value }, transaction, block, log}
* @param bind Gateway to your database.
* @param secrets Key value database that stores your secrets
*/
export const handler = async (context: IEventContext, bind: IBind, secrets: any) => {
let { from, to, value } = context.event;

// define your logics here.
}

Now, what are these three parameters?

  • context: It is a javascript object that contains information about a particular event that triggered this lambda, as our trigger type is a contract event. It contains event, transaction, block, log.
  • bind: This is your gateway to the managed database, this method returns a MongoDB instance of your database, and using this instance you can perform CRUD operations on your db.
  • secrets: Well we shouldn’t tell this, but these are secrets of your project. shhh….

! Do not change function names or params.

Now we have our battleground ready, we need to deploy our logic here.

So, the logic section is divided into four parts.

  1. Connecting Instance with Database.
  2. Reading from database
  3. Instance logics
  4. Updating the database.

Let’s get started then,

Connecting Instance with the database

As we already mentioned above the bind is your gateway to the database. Connecting with the database is an easy job.

We have a database ready, to connect with this database we need to pass its name into the bind function.

export const handler = async (context: IEventContext, bind: IBind, secrets: any) => {
let { from, to, value } = context.event;

// Let's create a connection to Balances database.
const balancesDB = bind(Balances);
}

And done! We have successfully created a connection to the database. Remember this connection supports the following mongoose method.

findOne(), updateOne(), save(), insertMany(), exists(), updateMany(), findOneAndUpdate(), replaceOne(), findOneAndReplace(), deleteOne(), deleteMany(), findOneAndDelete().

Now we have our connection ready, let’s move on to the next step.

Reading from database

As we have our Mongoose connection ready, let’s try to read something from the Balances db.

export const handler = async (context: IEventContext, bind: IBind, secrets: any) => {
let { from, to, value } = context.event;

// Let's create a connection to Balances database.
const balancesDB = bind(Balances);

// NOTE: Remeber all mongoose function returns a Promise, use await when interacting with database.

// We'll check that from address entry exists in database or not
let fromUser = await balancesDB.findOne({id: from.toLowerCase()});

// if the fromUser entry is empty, means there no document matching that id
// we will create a new user then.
fromUser ??= await balancesDB.create({id: from.toLowerCase()});
}

Isn’t it easy??

Moving onto the next part, defining logic.

Instance logics

As we previously mentioned we are trying to create a database that stores user balance information of that ERC20 token.

There are two users in the Transfer event, debit user (from address) and credit user (to address). So, we’ll need two documents from the balancesDB.

export const handler = async (context: IEventContext, bind: IBind, secrets: any) => {
let { from, to, value } = context.event;

// Let's create a connection to Balances database.
const balancesDB = bind(Balances);

// We'll check that from address entry exists in database or not
let fromUser = await balancesDB.findOne({id: from.toLowerCase()});

// if the fromUser entry is empty, means there no document matching that id
// we will create a new user then.
fromUser ??= await balancesDB.create({id: from.toLowerCase()});

// We'll check that to address entry exists in database or not
let toUser = await balancesDB.findOne({id: to.toLowerCase()});

// if the toUser entry is empty, means there no document matching that id
// we will create a new user then.
toUser ??= await balancesDB.create({id: to.toLowerCase()});
}

Now we have Mongo documents for both users ready, let’s apply some logic.

export const handler = async (context: IEventContext, bind: IBind, secrets: any) => {
let { from, to, value } = context.event;
// value is a bigNumber type, convert it to string.
value = value.toString();

const zeroAddress = "0x0000000000000000000000000000000000000000";

// Let's create a connection to Balances database.
const balancesDB = bind(Balances);

// We'll check that from address entry exists in database or not
let fromUser = await balancesDB.findOne({id: from.toLowerCase()});

// if the fromUser entry is empty, means there no document matching that id
// we will create a new user then.
fromUser ??= await balancesDB.create({id: from.toLowerCase()});

// We'll check that to address entry exists in database or not
let toUser = await balancesDB.findOne({id: to.toLowerCase()});

// if the toUser entry is empty, means there no document matching that id
// we will create a new user then.
toUser ??= await balancesDB.create({id: to.toLowerCase()});

fromUser.address = from.toString();
fromUser.transactionHash = context.transaction.transaction_hash.toString();
// remeber we defined allTimeHigh type as string!
fromUser.allTimeHigh = new BigNumber(fromUser.allTimeHigh || 0).toString();

toUser.address = to.toString();
toUser.transactionHash = context.transaction.transaction_hash.toString();
toUser.allTimeHigh = new BigNumber(toUser.allTimeHigh || 0).toString();

// if the interactedAddresses doesn't contains toUser address it means
// it's their first time transfering tokens.
if (!fromUser.interactedAddresses.includes(toUser.address))
fromUser.interactedAddresses.push(toUser.address);

// same goes with toUser interactedAddresses array
if (!toUser.interactedAddresses.includes(fromUser.address))
toUser.interactedAddresses.push(fromUser.address);

// As ERC20 tokens can be minted and burned, it is better to handle that case
if (from === zeroAddress) {
toUser.currentBalance = new BigNumber(toUser.currentBalance || 0)
.plus(value)
.toString();
} else if (to === zeroAddress) {
fromUser.currentBalance = new BigNumber(fromUser.currentBalance || 0)
.minus(value)
.toString();
} else {
fromUser.currentBalance = new BigNumber(fromUser.currentBalance || 0)
.minus(value)
.toString();
toUser.currentBalance = new BigNumber(toUser.currentBalance || 0)
.plus(value)
.toString();
}

// all time high logic
if (
new BigNumber(fromUser.currentBalance).gt(
new BigNumber(fromUser.allTimeHigh || 0)
)
)
fromUser.allTimeHigh = fromUser.currentBalance.toString();

if (
new BigNumber(toUser.currentBalance).gt(
new BigNumber(toUser.allTimeHigh || 0)
)
)
toUser.allTimeHigh = toUser.currentBalance.toString();

// Trade volume logics
if (fromUser.address !== zeroAddress)
fromUser.tradeVolume = new BigNumber(fromUser.tradeVolume || 0)
.plus(value)
.toString();

if (toUser.address !== zeroAddress)
toUser.tradeVolume = new BigNumber(toUser.tradeVolume || 0)
.plus(value)
.toString();
}

and logic is done, but wait the next step is the most crucial, yes!!! updating the database?

Updating the database

Last and the most crucial step, is to update the database. There is more than one way to update the Mongo database. (save, updateOne, updateMany).

But as we are not sure whether that particular user is either created or loaded from any previous entry. We will be using save which handles both cases.

export const handler = async (context: IEventContext, bind: IBind, secrets: any) => {
let { from, to, value } = context.event;
// value is a bigNumber type, convert it to string.
value = value.toString();

const zeroAddress = "0x0000000000000000000000000000000000000000";

// Let's create a connection to Balances database.
const balancesDB = bind(Balances);

// ... code from above

// saving the toUser, and fromUser into the database
await balancesDB.save(fromUser);
await balancesDB.save(toUser);
}

that’s it, we’ve successfully saved it into the database. But wait what about chain reorgs?

You don't need to worry about it, blockflow automatically resets and re-sync your database in case of chain reorgs. So, it’s like Reorg never happened!

So, in this tutorial, we learnt how to index an ERC20 token transfer, but what about consuming that data stored in Balances DB?

Well, that’s the topic for another guide! See ya!

(PS: You can use blockflow APIs to consume those data.)

--

--

Blockflow

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