DEV Community

Jamiebones
Jamiebones

Posted on • Updated on

Indexing Smart Contract Data Using The Graph Protocol

Introduction

Smart Contracts emit events that operate as a rich supply of data which can be aggregated. For instance, when tokens are transferred, an ERC20 token produces a transfer event. A developer or a user might index and aggregate this transfer event data and then run queries against it to learn more about the token's performance, such as the top holders, the volume of transactions, etc.

Blockchain data can be organized using the decentralized indexing technology known as the Graph Protocol. It uses the Graph Token (GRT), an ERC20 token, as a financial inducement for network security. The protocol allows for participation in four different roles, which are:

  • Developer
  • Indexer
  • Delegator
  • Curator

Developer

A subgraph is created by a developer who writes a schema and creates mappings to pull data into the schema entities. The subgraph is deployed to the Graph network, where an indexer indexes it.

Indexer

A node in the Graph Network is run by an indexer. An indexer stakes GRT so that it can run a node and index subgraphs. The indexer is compensated through network query fees.

Delegator

A delegator is a person who bets GRT on an indexer. A delegator is not required to run a node, but he or she can benefit from query fees by staking GRT on an indexer.

Curator

A Curator is someone who has enough money to tell indexers which subgraphs are worth indexing. They inspect the quality of subgraphs and assign weight to them by using GRT as signals on subgraphs so that indexers can find and index the subgraph.

You can read more about the roles here.

Creating a subgraph

Navigate to the Graph Studio and connect your wallet by signing a transaction. (No Eth will be charged ). Next, click on the Create Subgraph button to create and select the blockchain network you want the subgraph to index. I selected the Ethereum Mainnet because I want to index the Maker Dao Token found at this address. (0x6B175474E89094C44Da98b954EedeAC495271d0F).

Next, give your subgraph a unique name (In my case I named mine daitoken) and click on the continue button. Fill in the fields, describing what the subgraph does and click on save.

We will then proceed and create the subgraph in our development machine.

Take a note of the name of the subgraph. It will be needed when creating the subgraph on our machine.

Install the Graph CLI tool in our system by typing the code below at the terminal:

You must have Node installed on your computer

 npm install -g @graphprotocol/graph-cli
Enter fullscreen mode Exit fullscreen mode

After installing the CLI, we will need to initiate the subgraph by using the slug name which we created in the Graph Studio earlier.

graph init --studio <slug_name>
Enter fullscreen mode Exit fullscreen mode

Replace slug_name with the name of your created slug. Mine was daitoken, so my command to init will be:

graph init --studio daitoken
Enter fullscreen mode Exit fullscreen mode

After running the above command, you will be ask the following question by the tool:

  • Select Protocol Network : you should select Ethereum
  • Confirm your slug name : click on enter
  • Directory to create the subgraph in
  • Select the Ethereum network: mainnet
  • Contract address (the address of the contract you want to index) : 0x6B175474E89094C44Da98b954EedeAC495271d0F (this is the Maker Dai Token ) The tool will fetch the ABI from Etherscan, if it was not successful, you will have to compile the contract yourself and pass the location of the ABI to the tool.
  • Contract name : name of the contract you are indexing

Finally, the tool generates and creates your subgraph. Move into the created folder and take a look at the folders content. We are most interested in three files which are:

  • schema.graphql
  • subgraph.yaml
  • src/mappings.ts

Creating a Schema

The entities of the subgraph are contained in a schema. An entity is analogous to a table in a database, and its fields are analogous to columns. An entity may have a relationship with another entity at times. This is how an entity is represented:

  type Customer @entity {
    id: ID!
    name: String!
    address: Bytes!
  }

Enter fullscreen mode Exit fullscreen mode

Each entity must have a id field that cannot be null and is denoted by the ! sign, indicating that the field cannot be null.

If an entity will not be updated after it is created, it is usually written as immutable; this aids performance.

  //Immutable Customer Entity
  type Customer @entity(immutable: true) {
    id: ID!
    name: String!
    address: Bytes!
  }
Enter fullscreen mode Exit fullscreen mode

When representing a one-to-many relationship between entities, the one side is saved while the many side is derived. Consider a one-to-many relationship between a Customer entity and an Orders Entity. The one side of the relationship is saved in Orders, while the many side is derived in Customer.

   type Orders @entity {
     id : ID!
     amount: BigInt!
     token: String!
     buyer: Customer!
   }

    type Customer @entity {
    id: ID!
    name: String!
    address: Bytes!
    orders: [ Orders!]! @derivedFrom(field: "buyer")
  }
Enter fullscreen mode Exit fullscreen mode

We only save values for the id, name, and address fields when mapping data for the Customer entity. When we query the Customer's entity, the orders field will be derived from the 'Orders' entity.

The schema for the Maker Dai Token will consist of a User entity, a UserCounter entity and a TransferCounter entity. The created entities will be mapped to data inside the mappings.ts file. Open the file schema.graphql and paste the code below.

type User @entity {
  id: ID!
  address: String!
  balance: BigInt!
  transactionCount: Int!
}

type UserCounter @entity {
  id: ID!
  count: Int!
}

type TransferCounter @entity {
  id: ID!
  count: Int!
  totalTransferred: BigInt!
}

Enter fullscreen mode Exit fullscreen mode

The User entity contains fields of id, address, balance and transactionCount. These fields resolves to types of ID!, String, BigInt and Int respectively. The ! means that the field cannot be null. We are interested in saving the users of the token and their balance and the number of transactions they have made. We are also interested in the total user count and the total number of transfers made. Next, we will edit the manifest file.

Updating the Manifest file.

Open the file subgraph.yaml which is a configuration file. We will list the entities created above here, and also define the events on the smart contract we are interested in and handlers to pull the data into the entities.

specVersion: 0.0.2
schema:
  file: ./schema.graphql
dataSources:
  - kind: ethereum
    name: DaiToken
    network: mainnet
    source:
      address: "0x6B175474E89094C44Da98b954EedeAC495271d0F"
      abi: DaiToken
      startBlock: 8928158
    mapping:
      kind: ethereum/events
      apiVersion: 0.0.5
      language: wasm/assemblyscript
      entities:
        - User
        - UserCounter
        - TransferCounter
      abis:
        - name: DaiToken
          file: ./abis/DaiToken.json
      eventHandlers:
        - event: Transfer(indexed address,indexed address,uint256)
          handler: handleTransfer
      file: ./src/mapping.ts
Enter fullscreen mode Exit fullscreen mode

The startBlock which is under the source key signifies which block in the blockchain should the indexing start from. If we don't specify the startBlock, the indexing will start from the genesis block. You can get the block on which the contract was created from Etherscan.

Under the entities key we listed our created entities of :
User, UserCounter and TransferCounter. The event we are interested in is the Transfer event of the Token. The Transfer event takes three parameters of "indexed address,indexed address,uint256".

We define an handler to fire when the Transfer event occurs in the contract. This handler is named handleTransfer and it will be defined inside the mapping.ts file.

Lastly run the command below to auto generate types:

 graph codegen
Enter fullscreen mode Exit fullscreen mode

When we change our entities we should remember to run the above command so as to correctly generate the types.

Creating mapping handler

Open the mapping file located in the directory src/mapping.ts. The subgraph mapping is written in AssemblyScript, which appears to be similar to Typescript.


import { Transfer as TransferEvent } from "../generated/DaiToken/DaiToken";
import { User, UserCounter, TransferCounter } from "../generated/schema";
import { BigInt } from "@graphprotocol/graph-ts";

export function handleTransfer(event: TransferEvent): void {
  let day = event.block.timestamp.div(BigInt.fromI32(60 * 60 * 24));

  let userFrom = User.load(event.params.src.toHex());
  if (userFrom == null) {
    userFrom = newUser(event.params.src.toHex(), event.params.src.toHex());
  }
  userFrom.balance = userFrom.balance.minus(event.params.wad);
  userFrom.transactionCount = userFrom.transactionCount + 1;
  userFrom.save();

  let userTo = User.load(event.params.dst.toHex());
  if (userTo == null) {
    userTo = newUser(event.params.dst.toHex(), event.params.dst.toHex());

    // UserCounter
    let userCounter = UserCounter.load("singleton");
    if (userCounter == null) {
      userCounter = new UserCounter("singleton");
      userCounter.count = 1;
    } else {
      userCounter.count = userCounter.count + 1;
    }
    userCounter.save();
    userCounter.id = day.toString();
    userCounter.save();
  }
  userTo.balance = userTo.balance.plus(event.params.wad);
  userTo.transactionCount = userTo.transactionCount + 1;
  userTo.save();

  // Transfer counter total and historical
  let transferCounter = TransferCounter.load("singleton");
  if (transferCounter == null) {
    transferCounter = new TransferCounter("singleton");
    transferCounter.count = 0;
    transferCounter.totalTransferred = BigInt.fromI32(0);
  }
  transferCounter.count = transferCounter.count + 1;
  transferCounter.totalTransferred = transferCounter.totalTransferred.plus(
    event.params.wad
  );
  transferCounter.save();
  transferCounter.id = day.toString();
  transferCounter.save();
}

function newUser(id: string, address: string): User {
  let user = new User(id);
  user.address = address;
  user.balance = BigInt.fromI32(0);
  user.transactionCount = 0;
  return user;
}

Enter fullscreen mode Exit fullscreen mode

We imported our Transfer type and renamed it TransferEvent at the top of the file. To generate the types, we had to use graph codegen. We also imported our User, UserCounter, and TransferCounter entities.

import { Transfer as TransferEvent } from "../generated/DaiToken/DaiToken";
import { User, UserCounter, TransferCounter } from "../generated/schema";
Enter fullscreen mode Exit fullscreen mode

Inside the file we defined and exported a function with the following signature.

 export function handleTransfer(event: TransferEvent): void 
 {}
Enter fullscreen mode Exit fullscreen mode

This function receives as a parameter the TransferEvent. This function will be called for every transfer event that was created in the contract. The function does not return any output hence the return type of void.

All of the data that we want to save in the Graph node is contained in the event input parameter. We extract the day from the timestamp by dividing it by 86400 because this variable will be used as an identifier to save the unique transaction and user count reached each day.

let day = event.block.timestamp.div(BigInt.fromI32(60 * 60 * 24));
Enter fullscreen mode Exit fullscreen mode

The transfer event contains information about the user who made the transfer, the recipients of the transfer, and the amount transferred. To represent this, we created variables called userFrom and userTo.

let userFrom = User.load(event.params.src.toHex());
Enter fullscreen mode Exit fullscreen mode

We load the User entity from the Graph store if it exists, using the user's wallet address saved in the event.params.src property. Using the .toHex() method, we convert the wallet address to hexadecimal.

If userFrom is null, we use the utility function newUser to create and return a new 'User'. We subtract the amount transferred from the balance of the userFrom and increment the userFrom transaction count by one before saving the entity to the store.

The userTo variable is the beneficiary of the transfer, we check if the userTo exists by loading the data from the store.

 let userTo = User.load(event.params.dst.toHex());
Enter fullscreen mode Exit fullscreen mode

If userTo is null, we create a new user using the newUser function. Since this userTo address is new, we will want to add it to the UserCounter entity. We want to save and increment the UserCounter.count field and also save the historical user count on each day.

    let userCounter = UserCounter.load("singleton");
Enter fullscreen mode Exit fullscreen mode

This line loads the UserCounter from the store using the id singleton. If we don't have a UserCounter, it creates one and save the current count.

    if (userCounter == null) {
    //code removed
    userCounter.save();
    userCounter.id = day.toString();
    userCounter.save();
Enter fullscreen mode Exit fullscreen mode

After saving the userCounter created by using the singleton key as an id, we then defined another id which we equate to the day.toString() and also save the historical day count.

// assuming this is the current user count which we have saved
 userCount = {
       id : "Singleton",
       count: 4567
    }
//assuming day.toString() = 18219
userCount.id = day.toString()

userCount.save()
//calling userCount.save() again will save the example entity //into the store. This is the historical count data
userCount = {
  id : "18219",
  count: 4567
}
Enter fullscreen mode Exit fullscreen mode

We save the TransferCounter in the same way that we saved the UserCounter. We save the daily count and amount transferred, as well as the cumulative sum of the amount transferred and transaction count, to the store.

Deploying the subgraph to the studio

We have to authenticate from the terminal by running this code:

 graph auth --studio <deployment_key>
Enter fullscreen mode Exit fullscreen mode

Your deployment key can be found on the dashboard of your Graph studio.

Next we build the project by running:

 graph build
Enter fullscreen mode Exit fullscreen mode

To deploy the graph to the studio we run :

graph deploy --studio <slug_name>
Enter fullscreen mode Exit fullscreen mode

The slug_name is the subgraph's name. It must be the same as what was specified in Graph Studio. You'll be prompted to enter a version number for the deployed subgraph.

You will not be able to delete a deployed subgraph.
You can only change it and republish it as a new version.

After deploying my subgraph, I was given a development url for testing purposes. The Graph's hosted service will begin indexing the subgraph. It will take some time before you can begin querying the subgraph.

Writing queries to query our subgraph

Our deployed subgraph contained three entities namely User, UserCounter and TransferCounter. We can use the studio playground to write GraphQL queries to retrieve our entities. We can retrieve a single entity or a collection of entitites.

See below some sample queries we could make to our subgraph.

  • query to retrieve the first 100 users records
  {
    users(first: 100){
      id
      address
      balance
      transactionCount
   }
  }
Enter fullscreen mode Exit fullscreen mode
  • query to retrieve the top 10 Dai token holders
  {
  users(first:10, orderBy: balance, orderDirection: desc){
    id
    balance
    transactionCount
    address
  }
}

Enter fullscreen mode Exit fullscreen mode
  • query to get the total number of users that have used the Dai token
 {
   userCounter(id: "singleton"){
     id
     count
  }
}
Enter fullscreen mode Exit fullscreen mode
  • query to retrieve a singe user
  {
  user(id: "wallet address"){
    id
    balance
    transactionCount
    address
  }
}

Enter fullscreen mode Exit fullscreen mode

Finally, we could make our subgraph available to the decentralized network. The Graph hosted service is currently indexing our deployed subgraph. To make our subgraph decentralized, we should publish it to the network, where indexers will pick it up and index it. The code for the tutorial is available here

I hope you learned something useful from this tutorial. Thank you for your time.

Top comments (0)