Step 1: Indexing Transfer events
In this step-by-step tutorial we will build a squid that gets data about Bored Ape Yacht Club NFTs, their transfers and owners from the Ethereum blockchain, indexes the NFT metadata from IPFS and regular HTTP URLs, stores all the data in a database and serves it over a GraphQL API. Here we do the first step: build a squid that indexes only theTransfer events emitted by the BAYC token contract.
Pre-requisites: Node.js, Squid CLI, Docker.
Starting with a template
Begin by retrieving theevm template and installing the dependencies:
Interfacing with the contract ABI
First, we inspect which data is available for indexing. For EVM contracts, the metadata descrbing the shape of the smart contract logs, transactions and contract state methods is distributed as an Application Binary Interface (ABI) JSON file. For many popular contracts ABI files are published on Etherscan (as in the case of the BAYC NFT contract). SQD provides a tool for retrieving contract ABIs from Etherscan-like APIs and generating the boilerplate for retrieving and decoding the data. For the contract of interest, this can be done withsrc/abi is the destination folder and the bayc suffix sets the base name for the generated file.
Checking out the generated src/abi/bayc.ts file we see all events and contract functions listed in the ABI. Among them there is the Transfer event:
Configuring the data filters
A “squid processor” is the Node.js process and the object that powers it. Together they are responsible for retrieving filtered blockchain data from a specialized data lake (SQD Network), transforming it and saving the result to a destination of choice. To configure the processor (object) to retrieve theTransfer events of the BAYC token contract, we initialize it like this:
src/processor.ts
'https://v2.archive.subsquid.io/network/ethereum-mainnet'is the URL the public SQD Network gateway for Ethereum mainnet. Check out the exhaustive SQD Network gateways list.'<eth_rpc_endpoint_url>'is a public RPC endpoint we chose to use in this example. When an endpoint is available, the processor will begin ingesting data from it once it reaches the highest block available within SQD Network. Please use a private endpoint or SQD Cloud’s RPC addon in production.setFinalityConfirmation(75)call instructs the processor to consider blocks final after 75 confirmations when ingesting data from an RPC endpoint.12_287_507is the block at which the BAYC token contract was deployed. Can be found on the contract’s Etherscan page.- The argument of
addLog()is a set of filters that tells the processor to retrieve all event logs emitted by the BAYC contract with topic0 matching the hash of the full signature of theTransferevent. The hash is taken from the previously generated Typescript ABI. - The argument of
setFields()specifies the exact data we need on every event to be retrieved. In addition to the data that is provided by default we are requesting hashes of parent transactions for all event logs.
Decoding the event data
The other part of processor configuration is the callback function used to process batches of the filtered data, the batch handler. It is typically defined at theprocessor.run() call at src/main.ts, like this:
dbis aDatabaseimplementation specific to the target data sink. We want to store the data in a PostgreSQL database and present with a GraphQL API, so we provide aTypeormDatabaseobject here.ctxis a batch context object that exposes a batch of data retrieved from SQD Network or a RPC endpoint (atctx.blocks) and any data persistence facilities derived fromdb(atctx.store).
Transfer event:
src/main.ts
Transfer events emitted by the BAYC contract and decodes the data of each log item, then logs the results to the terminal. The verification step is required because the processor does not guarantee that it won’t supply any extra data, only that it will supply the data matching the filters. The decoding is done with the bayc.events.Transfer.decode() function from the Typescript ABI we previously generated.
At this point the squid is ready for its first test run. Execute
Extending and persisting the data
TypeormDatabase requires us to define a TypeORM data model to actually send the data to the database. In SQD, the same data model is also used by the GraphQL server to generate the API schema. To avoid any potential discrepancies, processor and GraphQL server rely on a shared data model description defined at schema.graphql in a GraphQL schema dialect fully documented here.
TypeORM code is generated from schema.graphql with the squid-typeorm-codegen tool and must be regenerated every time the schema is changed. This is usually accompanied by regenerating the database migrations and recreating the database itself. The migrations are applied before every run of the processor, ensuring that whenever any TypeORM code within the processor attempts to access the database, the database is in a state that allows it to succeed.
The main unit of data in schema.graphql is entity. These map onto TypeORM entites that in turn map onto database tables. We define one for Transfer events by replacing the file contents with
schema.graphql
- The
idis a required field and theIDtype is an alias forString. Entity IDs must be unique. @indexdecorators tell the codegen tool that the corresponding database columns should be indexed.- Extra fields
timestampandblockNumberare added to make the resulting GraphQL API more convenient. We will fill them using the block metadata available inctx.
src/model. We can now import a Transfer entity class from there and use it to perform various operations on the corresponding database table. Let us rewrite our batch handler to save the parsed Transfer events data to the database:
src/main.ts
- A unique event log ID is available at
log.id- no need to generate your own! tokenIdreturned from the decoder is anethers.BigNumber, so it has to be explicitly converted tonumber. The conversion is valid only because we know that BAYC NFT IDs run from 0 to 9999; in most cases we would useBigIntfor the entity field type and convert withtokenId.toBigInt().block.headercontains block metadata that we use to fill the extra fields.- Accumulating the
Transferentity instances before usingctx.store.insert()on the whole array of them in the end allows us to get away with just one database transaction per batch. This is crucial for achieving a good syncing performance.
