Index & Query Berachain Data with Goldsky š§®
What is Goldsky and Why Should I Care about Indexing?
Goldsky lets developers build GraphQL APIs that store and query data from the blockchain. This data is highly customizable and can be used to uncover trends such as token supply growth over time, or instantaneous data such as user token balances.
A subgraph contains logic for indexing data from a blockchain, transforming raw data and storing it in a form that can be easily queried.
In this tutorial, we teach you to develop a subgraph which queries usersā ERC20 balances on the Berachain network.
Pre-requisites š
- Nodejs
v20.11.0
or greater - pnpm
- An IDE e.g. VSCode, Replit, etc.
For those with existing subgraphs deployed on The Graph who want to deploy to Berchain/Goldsky, skip to the section Getting Set up on Goldsky. Goldsky and The Graph subgraphs are fully compatible with one another!
Building your Subgraph š ļø
To begin, enter in your terminal:
mkdir goldsky-subgraph;
cd goldsky-subgraph;
pnpm init;
pnpm install @graphprotocol/graph-cli @graphprotocol/graph-ts;
# Accept all of the defaults, hitting enter when prompted
# name: (project-name) project-name
# version: (0.0.0) 0.0.1
# description: The Project Description
# entry point: //leave empty
# test command: //leave empty
# git repository: //the repositories url
# keywords: //leave empty
# author: // your name
# license: N/A
# @graphprotocol dependencies provide subgraph development tooling
In the root of your project, create the following project structure (empty files are fine for now):
# FROM: ./goldsky-subgraph;
.
āāā abis
ā āāā Erc20.json
āāā package.json
āāā schema.graphql
āāā src
ā āāā mapping.ts
ā āāā utils.ts
āāā subgraph.yaml
In ./abis/Erc20.json
paste in the contents below, which define the ERC20 contract interface:
[
{
"constant": true,
"inputs": [],
"name": "name",
"outputs": [{ "name": "", "type": "string" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{ "name": "_spender", "type": "address" },
{ "name": "_value", "type": "uint256" }
],
"name": "approve",
"outputs": [{ "name": "", "type": "bool" }],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "totalSupply",
"outputs": [{ "name": "", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{ "name": "_from", "type": "address" },
{ "name": "_to", "type": "address" },
{ "name": "_value", "type": "uint256" }
],
"name": "transferFrom",
"outputs": [{ "name": "", "type": "bool" }],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [{ "name": "", "type": "uint8" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [{ "name": "_owner", "type": "address" }],
"name": "balanceOf",
"outputs": [{ "name": "balance", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "symbol",
"outputs": [{ "name": "", "type": "string" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{ "name": "_to", "type": "address" },
{ "name": "_value", "type": "uint256" }
],
"name": "transfer",
"outputs": [{ "name": "", "type": "bool" }],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [
{ "name": "_owner", "type": "address" },
{ "name": "_spender", "type": "address" }
],
"name": "allowance",
"outputs": [{ "name": "", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{ "payable": true, "stateMutability": "payable", "type": "fallback" },
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "owner", "type": "address" },
{ "indexed": true, "name": "spender", "type": "address" },
{ "indexed": false, "name": "value", "type": "uint256" }
],
"name": "Approval",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "from", "type": "address" },
{ "indexed": true, "name": "to", "type": "address" },
{ "indexed": false, "name": "value", "type": "uint256" }
],
"name": "Transfer",
"type": "event"
}
]
Configuring the Subgraph
The primary step in creating a subgraph is to define the data sources we read from, and the data structures (entities) we index this data into. This is accomplished in the subgraph.yaml
, or the subgraph manifest.
In ./subgraph.yaml
paste the following:
specVersion: 0.0.4
description: ERC-20 subgraph with event handlers & entities
schema:
file: ./schema.graphql
dataSources:
- kind: ethereum/contract
name: Erc20
network: berachain-bartio
source:
address: "0x1306D3c36eC7E38dd2c128fBe3097C2C2449af64"
abi: Erc20
startBlock: 88948
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
entities:
- Token
- Account
- TokenBalance
abis:
- name: Erc20
file: ./abis/Erc20.json
eventHandlers:
- event: Transfer(indexed address,indexed address,uint256)
handler: handleTransfer
file: ./src/mapping.ts
In this manifest, a few things are worth pointing out:
source
: The address is the bHONEY token, which employs theErc20
interface, to be indexed from thestartBlock
it was deployedentities
can be thought of as JavaScript objects which can be queried. Entities can have relational mappings between one another and are defined in a GraphQL schema (below)eventHandlers
: Every time a tokenTransfer
event is emitted, thehandleTransfer
method in./src/mapping.ts
is called to perform indexing logic
Writing the Schema
In ./schema.graphql
we define our schema, containing properties and relationships of the different entities existing within our subgraph:
# Token details
type Token @entity {
id: ID!
#token name
name: String!
#token symbol
symbol: String!
#decimals used
decimals: BigDecimal!
}
# account details
type Account @entity {
#account address
id: ID!
#balances
balances: [TokenBalance!]! @derivedFrom(field: "account")
}
# token balance details
type TokenBalance @entity {
id: ID!
#token
token: Token!
#account
account: Account!
#amount
amount: BigDecimal!
}
The Token
entity is straightforward, containing well-known properties of ERC20 tokens.
The Account
entity contains an id
of the wallet address and more interestingly, a list of balances
of the type TokenBalance
. The @derivedFrom
directive means that an Accountās balances
property is defined by reverse lookup, based on the account
property in TokenBalance
entities.
TokenBalance
leverages both Account
and Token
entities, to define the token balance of each particular user.
For this tutorial, itās wasnāt absolutely necessary to have both Token
and TokenBalance
entities. Usersā MIM balances could conceivably have been captured in just the Account
entity. However, this design makes it possible to extend the subgraph to capture multiple token balances.
Creating Mappings
The mapping file is where everything comes together āØ Blockchain data is associated with the entities we have defined in our schema. Below, we define the interations that happen when the handleTransfer
event handler is invoked.
In ./src/mapping.ts
add the following code:
//import event class from generated files
import { Transfer } from "../generated/Erc20/Erc20";
//import the functions defined in utils.ts
import { fetchTokenDetails, fetchAccount, updateTokenBalance } from "./utils";
//import datatype
import { BigInt } from "@graphprotocol/graph-ts";
export function handleTransfer(event: Transfer): void {
// 1. Get token details
let token = fetchTokenDetails(event);
if (!token) {
return;
}
// 2. Get account details
let fromAddress = event.params.from.toHex();
let toAddress = event.params.to.toHex();
let fromAccount = fetchAccount(fromAddress);
let toAccount = fetchAccount(toAddress);
if (!fromAccount || !toAccount) {
return;
}
// 3. Update the token balances
// Setting the token balance of the 'from' account
updateTokenBalance(
token,
fromAccount,
BigInt.fromI32(0).minus(event.params.value)
);
// Setting the token balance of the 'to' account
updateTokenBalance(token, toAccount, event.params.value);
}
The handleTransfer
takes the Transfer
event as a parameter, containing the information (fromAddress, toAddress, transferAmount)
. With this info, the handler is empowered to perform the following functions:
- Fetch token details
- Fetch account details
- Update account balances
At a high level, this handler code will dutifuly update ERC20 account balances every time a Transfer
event is emitted.
Working With Entities
You may have noticed that the code in mapping.ts
is rather simpleāāāthatās because the heavy lifting of interacting with entities has been abstracted into a utility file. Letās go over the nuts and bolts of working with subgraph entities now.
In ./src/utils.ts
add the following code:
//import smart contract class from generated files
import { Erc20 } from "../generated/Erc20/Erc20";
//import entities
import { Account, Token, TokenBalance } from "../generated/schema";
//import datatypes
import { BigDecimal, ethereum, BigInt } from "@graphprotocol/graph-ts";
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// Fetch token details
export function fetchTokenDetails(event: ethereum.Event): Token | null {
//check if token details are already saved
let token = Token.load(event.address.toHex());
if (!token) {
//if token details are not available
//create a new token
token = new Token(event.address.toHex());
//set some default values
token.name = "N/A";
token.symbol = "N/A";
token.decimals = BigDecimal.fromString("0");
//bind the contract
let erc20 = Erc20.bind(event.address);
//fetch name
let tokenName = erc20.try_name();
if (!tokenName.reverted) {
token.name = tokenName.value;
}
//fetch symbol
let tokenSymbol = erc20.try_symbol();
if (!tokenSymbol.reverted) {
token.symbol = tokenSymbol.value;
}
//fetch decimals
let tokenDecimal = erc20.try_decimals();
if (!tokenDecimal.reverted) {
token.decimals = BigDecimal.fromString(tokenDecimal.value.toString());
}
//save the details
token.save();
}
return token;
}
// Fetch account details
export function fetchAccount(address: string): Account | null {
//check if account details are already saved
let account = Account.load(address);
if (!account) {
//if account details are not available
//create new account
account = new Account(address);
account.save();
}
return account;
}
export function updateTokenBalance(
token: Token,
account: Account,
amount: BigInt
): void {
// Don't update zero address
if (ZERO_ADDRESS == account.id) return;
// Get existing account balance or create a new one
let accountBalance = getOrCreateAccountBalance(account, token);
let balance = accountBalance.amount.plus(bigIntToBigDecimal(amount));
// Update the account balance
accountBalance.amount = balance;
accountBalance.save();
}
function getOrCreateAccountBalance(
account: Account,
token: Token
): TokenBalance {
let id = token.id + "-" + account.id;
let tokenBalance = TokenBalance.load(id);
// If balance is not already saved
// create a new TokenBalance instance
if (!tokenBalance) {
tokenBalance = new TokenBalance(id);
tokenBalance.account = account.id;
tokenBalance.token = token.id;
tokenBalance.amount = BigDecimal.fromString("0");
tokenBalance.save();
}
return tokenBalance;
}
function bigIntToBigDecimal(quantity: BigInt, decimals: i32 = 18): BigDecimal {
return quantity.divDecimal(
BigInt.fromI32(10)
.pow(decimals as u8)
.toBigDecimal()
);
}
fetchAccount()
returns anAccount
entity. We first see whether anAccount
entity with the passed inaddress
exists. If it doesnāt, we create a new onefetchTokenDetails()
returns aToken
entity. If noToken
exists, we instantiate a new one by binding the token address with theERC20
interface, letting us access the public read functions from the token contract. This allows us to retrieve and set token properties such as name, symbol and decimalsupdateTokenBalance()
is perhaps the most important, as it updates usersāTokenBalance
entities with each transfer. Referring back to themapping.ts
file, this function is invoked twice with eachTransfer
eventāāāa negativeamount
is passed in for the transferer, denoting a loss of token balance, and conversely, a positiveamount
for the recipient. This way, an accurate accounting of token balances is maintained
Building the Subgraph
In the root of your project directory, run the following in your terminal:
# FROM: ./goldsky-subgraph;
pnpm codegen;
pnpm build;
These commands generate TypeScript class files from your contract ABIs, compiles your code, and creates build outputs in the /build
directory.
Before we deploy, we have to get you set up on Goldsky.
Getting Set up on Goldsky
Goldsky will host your subgraph and do all the necessary indexing. Follow the instructions below to get your account set up:
- Create an account at app.goldsky.com
- Create an API key on the Settings page
- Install the Goldsky CLI:
curl https://goldsky.com | sh
4. Log in with the API key created earlier:
goldsky login
Deploy your Subgraph š
In the root of your project, run the following:
# FROM: ./goldsky-subgraph;
goldsky subgraph deploy erc20-subgraph/1.0.0 --path .
Once successfully deployed, view your deployed subgraph. It wonāt be usable right away, because the indexing process requires going over every block to update token balances.
Querying your Data
In your dashboard, you will see a āPublic GraphQL linkā which provides a nice interface for you to write queries. Now letās give our new subgraph a spin with this query:
{
accounts {
id
balances {
id
token {
id
name
symbol
decimals
}
amount
}
}
}
If you want an example to play along with, use this live subgraph.
Cross checking usersās MIM balances against Berachainās block explorer, we see that our subgraph has accurately indexed usersā token balances ā
Recap
And that, frens, is how you use Goldsky subgraphs to index token balances of Berachain wallets. Goldsky is a data-availability platform, providing a way for developers to easily store and query customized blockchain data.
š» Full Code Repository
If you want to see the final code and see other guides, check out Berachain Goldsky Guide Code.
š ļø Want To Build More?
Want to build more on Berachain and see more examples. Take a look at our Berachain GitHub Guides Repo for a wide variety of implementations that include NextJS, Hardhat, Viem, Foundry, and more.
If youāre looking to dive deeper into the details, take a look at our Berachain Docs.
Looking For Dev Support?
Make sure to join our Berachain Discord server and check out our developer channels to ask questions.
ā¤ļø Donāt forget to show some love for this article šš¼