Create an EVM-processing Squid

Objective

This tutorial will take the Squid EVM template and go through all the necessary steps to customize the project, in order to interact with a different Squid Archive, synchronized with a different blockchain, and process data from Events different from the ones in the template.
The business logic to process such Events is very basic, and that is on purpose since the purpose of the Tutorial is to show a simple case, highlighting the changes a developer would typically apply to the template, removing unnecessary complexity.
The blockchain used in this example will be the Astar network and the final objective will be to observe which files have been added and deleted from the chain, as well as groups joined and storage orders placed by a determined account.

Pre-requisites

The minimum requirements to follow this tutorial are the basic knowledge of software development, such as handling a Git repository, a correctly set up Development Environment, basic command line knowledge and the concepts explained in this documentation.

Fork the template

The first thing to do, although it might sound trivial to GitHub experts, is to fork the repository into your own GitHub account, by visiting the repository page and clicking the Fork button:
How to fork a repository on GitHub
Next, clone the created repository (be careful of changing <account> with your own account)
1
git clone [email protected]:<account>/squid-evm-template.git
Copied!
For reference on the complete work, you can find the entire project here.

Run the project

Next, just follow the Quickstart to get the project up and running, here's a list of commands to run in quick succession:
1
npm ci
2
npm run build
3
docker compose up -d
4
npx sqd db create
5
npx sqd db migrate
6
node -r dotenv/config lib/processor.js
7
# open a separate terminal for this next command
8
npx squid-graphql-server
Copied!
Bear in mind this is not strictly necessary, but it is always useful to check that everything is in order. If you are not interested, you could at least get the Postgres container running with docker compose up -d.

Define Entity Schema

The next thing to do, in order to customize the project for our own purpose, is to make changes to the schema and define the Entities we want to keep track of.
Luckily, the EVM template already contains a schema that defines the exact entities we need for the purpose of this guide. For this reason, changes are necessary, but it's still useful to explain what is going on.
To index ERC-721 token transfers, we will need to track:
  • Token transfers
  • Ownership of tokens
  • Contracts and their minted tokens
And the schema.graphql file defines them like shis:
1
type Token @entity {
2
id: ID!
3
owner: Owner
4
uri: String
5
transfers: [Transfer!]! @derivedFrom(field: "token")
6
contract: Contract
7
}
8
9
type Owner @entity {
10
id: ID!
11
ownedTokens: [Token!]! @derivedFrom(field: "owner")
12
balance: BigInt
13
}
14
15
type Contract @entity {
16
id: ID!
17
name: String
18
symbol: String
19
totalSupply: BigInt
20
mintedTokens: [Token!]! @derivedFrom(field: "contract")
21
}
22
23
type Transfer @entity {
24
id: ID!
25
token: Token!
26
from: Owner
27
to: Owner
28
timestamp: BigInt!
29
block: Int!
30
transactionHash: String!
31
}
Copied!
It's worth noting a couple of things in this schema definition:
  • @entity - signals that this type will be translated into an ORM model that is going to be persisted in the database
  • @derivedFrom - signals the field will not be persisted on the database, it will rather be derived
  • type references (i.e. from: Owner) - establishes a relation between two entities
The template already has automatically generated TypeScript classes for this schema definition. They can be found under src/model/generated.
Whenever changes are made to the schema, new TypeScript entity classes have to be generated, and to do that you'll have to run the codegen tool:
1
npx sqd codegen
Copied!

ABI Definition and Wrapper

Subsquid offers support for automatically building TypeScript type-safe interfaces for Substrate data sources (events, extrinsics, storage items). Changes are automatically detected in the runtime.
This functionality has been extended to EVM indexing, with the release of an evm-typegen tool to generate TypeScript interfaces and decoding functions for EVM logs.
Once again, the template repository already includes interfaces for ERC-721 contracts, which is the subject of this guide. But it is still important to explain what needs to be done, in case, for example, one wants to index a different type of contract.
First of all, it is necessary to obtain the definition of its Application Binary Interface (ABI). This can be obtained in the form of a JSON file, which will be imported into the project.
  1. 1.
    It is advisable to copy the JSON file in the src/abis subfolder.
  2. 2.
    To automatically generate TypeScript interfaces from an ABI definition, and decode event data, simply run this command from the project's root folder
1
npx squid-evm-typegen --abi src/abi/ERC721.json --output src/abi/erc721.ts
Copied!
The abi parameter points at the JSON file previously created, and the output parameter is the name of the file that will be generated by the command itself.
This command will automatically generate a TypeScript file named erc721.ts, under the src/abi subfolder, that defines data interfaces to represent output of the EVM events defined in the ABI, as well as a mapping of the functions necessary to decode these events (see the events dictionary in the aforementione file).
!!! note The ERC-721 ABI defines the signatures of all events in the contract. The Transfer event has three arguments, named: from, to, and tokenId. Their types are, respectively, address, address, and uint256. As such, the actual definition of the Transfer event looks like this: Transfer(address, address, uint256).

Define and Bind Event Handler(s)

The Subsquid SDK provides users with a processor class, named SubstrateProcessor or, in this specific case SubstrateEvmProcessor. The processor connects to the Subsquid archive to get chain data. It will index from the configured starting block, until the configured end block, or until new data is added to the chain.
The processor exposes methods to "attach" functions that will "handle" specific data such as Substrate events, extrinsics, storage items, or EVM logs. These methods can be configured by specifying the event or extrinsic name, or the EVM log contract address, for example. As the processor loops over the data, when it encounters one of the configured event names, it will execute the logic in the "handler" function.

Managing the EVM contract

It is worth pointing out, at this point, that some important auxiliary code like constants and helper functions to manage the EVM contract is defined in the src/contracts.ts file. Here's a summary of what is in it:
  • Define the chain node endpoint (optional but useful)
  • Create a contract interface to store information such as the address and ABI
  • Define functions to fetch a contract entity from the database or create one
  • Define the processTransfer EVM log handler, implementing logic to track token transfers
In order to adapt the template to the scope of this guide, we need to apply a couple of changes:
  1. 1.
    edit the CHAIN_NODE constant to the endpoint URL of Astar network (e.g. wss://astar.api.onfinality.io/public-ws)
  2. 2.
    edit the hexadecimal address used to create the contract constant (we are going to use this token for the purpose of this guide)
  3. 3.
    change the name, symbol and totalSupply values used in the createContractEntity function to their correct values (see link in the previous point)
In case someone wants to index an EVM event different from Transfer, they would also have to implement a different handler function from processTransfer, especially the line where the event "Transfer(address,address,uint256)" is decoded.
1
// src/contract.ts
2
import { assertNotNull, EvmLogHandlerContext, Store } from "@subsquid/substrate-evm-processor";
3
import { ethers } from "ethers";
4
import { Contract, Owner, Token, Transfer } from "./model";
5
import { events, abi } from "./abi/erc721"
6
7
export const CHAIN_NODE = "wss://astar.api.onfinality.io/public-ws";
8
9
export const contract = new ethers.Contract(
10
"0xd59fC6Bfd9732AB19b03664a45dC29B8421BDA9a",
11
abi,
12
new ethers.providers.WebSocketProvider(CHAIN_NODE)
13
);
14
15
export function createContractEntity(): Contract {
16
return new Contract({
17
id: contract.address,
18
name: "AstarDegens",
19
symbol: "DEGEN",
20
totalSupply: 10000n,
21
});
22
}
23
24
let contractEntity: Contract | undefined;
25
26
export async function getContractEntity({
27
store,
28
}: {
29
store: Store;
30
}): Promise<Contract> {
31
if (contractEntity == null) {
32
contractEntity = await store.get(Contract, contract.address);
33
}
34
return assertNotNull(contractEntity);
35
}
36
37
38
export async function processTransfer(ctx: EvmLogHandlerContext): Promise<void> {
39
const transfer =
40
events["Transfer(address,address,uint256)"].decode(ctx);
41
42
let from = await ctx.store.get(Owner, transfer.from);
43
if (from == null) {
44
from = new Owner({ id: transfer.from, balance: 0n });
45
await ctx.store.save(from);
46
}
47
48
let to = await ctx.store.get(Owner, transfer.to);
49
if (to == null) {
50
to = new Owner({ id: transfer.to, balance: 0n });
51
await ctx.store.save(to);
52
}
53
54
let token = await ctx.store.get(Token, transfer.tokenId.toString());
55
if (token == null) {
56
token = new Token({
57
id: transfer.tokenId.toString(),
58
uri: await contract.tokenURI(transfer.tokenId),
59
contract: await getContractEntity(ctx),
60
owner: to,
61
});
62
await ctx.store.save(token);
63
} else {
64
token.owner = to;
65
await ctx.store.save(token);
66
}
67
68
await ctx.store.save(
69
new Transfer({
70
id: ctx.txHash,
71
token,
72
from,
73
to,
74
timestamp: BigInt(ctx.substrate.block.timestamp),
75
block: ctx.substrate.block.height,
76
transactionHash: ctx.txHash,
77
})
78
);
79
}
Copied!
The "handler" function takes in a Context of the correct type (EvmLogHandlerContext, in this case). The context contains the triggering event and the interface to store data, and is used to extract and process data and save it to the database.
!!! note For the event handler, it is also possible to bind an "arrow function" to the processor.

Configure Processor and Attach Handler

The src/processor.ts file is where the template project instantiates the SubstrateEvmProcessor class, configures it for execution, and attaches the handler functions(s).
Luckily for us, most of the job is already done. It is important to note that, since the template was built for the moonriver network, there are a couple of things to change:
  1. 1.
    change the name argument passed to SubstrateEvmProcessor constructor (not necessary, but good practice)
  2. 2.
    Change the archive parameter of the setDataSource function to fetch the Archive URL for Astar.
  3. 3.
    Change the argument passed to the setTypesBundle function to "astar".
Look at this code snippet for the end result:
1
// src/processor.ts
2
import { SubstrateEvmProcessor } from "@subsquid/substrate-evm-processor";
3
import { lookupArchive } from "@subsquid/archive-registry";
4
import {
5
CHAIN_NODE,
6
contract,
7
createContractEntity,
8
processTransfer,
9
} from "./contract";
10
import { events } from "./abi/erc721";
11
12
const processor = new SubstrateEvmProcessor("astar-substrate");
13
14
processor.setBatchSize(500);
15
16
processor.setDataSource({
17
chain: CHAIN_NODE,
18
archive: lookupArchive("astar")[0].url,
19
});
20
21
processor.setTypesBundle("astar");
22
23
processor.addPreHook({ range: { from: 0, to: 0 } }, async (ctx) => {
24
await ctx.store.save(createContractEntity());
25
});
26
27
processor.addEvmLogHandler(
28
contract.address,
29
{
30
filter: [events["Transfer(address,address,uint256)"].topic],
31
},
32
processTransfer
33
);
34
35
processor.run();
Copied!
!!! note The lookupArchive function is used to consult the archive registry{target=_blank} and yield the archive address, given a network name. Network names should be in lowercase.

Launch and Set Up the Database

When running the project locally, as it is the case for this guide, it is possible to use the docker-compose.yml file that comes with the template to launch a PostgreSQL container. To do so, run the following command in your terminal:
1
docker-compose up -d
Copied!
Launch database container
!!! note The -d parameter is optional, it launches the container in daemon mode so the terminal will not be blocked and no further output will be visible.
Squid projects automatically manage the database connection and schema, via an ORM abstraction.
To set up the database, you can take the following steps:
  1. 1.
    Build the code
    1
    npm run build
    Copied!
  2. 2.
    Remove the template's default migration:
    1
    rm -rf db/migrations/*.js
    Copied!
  3. 3.
    Make sure the Postgres Docker container, squid-evm-template_db_1, is running
    1
    docker ps -a
    Copied!
  4. 4.
    Drop the current database (if you have never run the project before, this is not necessary), create a new database, create the initial migration, and apply the migration
    1
    npx sqd db drop
    2
    npx sqd db create
    3
    npx sqd db create-migration Init
    4
    npx sqd db migrate
    Copied!

Launch the Project

To launch the processor (this will block the current terminal), you can run the following command:
1
node -r dotenv/config lib/processor.js
Copied!
Launch processor
Finally, in a separate terminal window, launch the GraphQL server:
1
npx squid-graphql-server
Copied!
Visit localhost:4350/graphql to access the GraphiQl console. From this window, you can perform queries such as this one, to find out the account owners with the biggest balances:
1
query MyQuery {
2
owners(limit: 10, where: {}, orderBy: balance_DESC) {
3
balance
4
id
5
}
6
}
Copied!
Or this other one, looking up the tokens owned by a given owner:
1
query MyQuery {
2
tokens(where: {owner: {id_eq: "0x1210F3eA18Ef463c162FFF9084Cee5B6E5ccAb37"}}) {
3
uri
4
contract {
5
id
6
name
7
symbol
8
totalSupply
9
}
10
}
11
}
Copied!
Have some fun playing around with queries, after all, it's a playground!