Indexando transferências ERC-20 em uma rede EVM Tanssi¶
Introdução¶
SQD é uma rede de dados que permite recuperar informações de mais de 100 cadeias usando um data lake descentralizado e um SDK open source. Em termos simples, é um ETL com servidor GraphQL embutido, oferecendo filtragem, paginação e busca em texto.
A SQD oferece suporte nativo a EVM e Substrate, com arquivos/processadores para ambos. Assim, é possível extrair logs EVM e entidades Substrate (eventos, extrínsecos, storage) em um único projeto e servir via um único endpoint GraphQL. Para apenas dados EVM, use o arquivo/processador EVM.
Este tutorial mostra, passo a passo, como criar um Squid para indexar dados EVM. Há uma versão completa no repositório tanssiSquid.
Verificar pré-requisitos¶
Você precisará de:
- Docker instalado
- Docker Compose instalado
- Um projeto Hardhat vazio (veja Criando um Projeto Hardhat)
Note
Os exemplos deste guia partem de um ambiente MacOS ou Ubuntu 20.04. Se estiver usando Windows, adapte os comandos conforme necessário.
Verifique também se você tem o Node.js e um gerenciador de pacotes (como npm ou yarn) instalados. Para saber como instalar o Node.js, consulte a documentação oficial.
Além disso, certifique-se de ter inicializado um arquivo package.json para módulos ES6. Você pode criar um package.json padrão com npm executando npm init --yes.
Implantar um ERC-20 com Hardhat¶
Implante um token para ter eventos a indexar (ou use um já existente na demo EVM). Exemplo de contrato MyTok.sol:
1) Instale dependências:
npm install @nomicfoundation/hardhat-ethers ethers @openzeppelin/contracts
yarn add @nomicfoundation/hardhat-ethers ethers @openzeppelin/contracts
2) Ajuste hardhat.config.js com sua RPC/conta:
hardhat.config.js
// 1. Import the Ethers plugin required to interact with the contract
require('@nomicfoundation/hardhat-ethers');
// 2. Add your private key that is funded with tokens of your Tanssi-powered network
// This is for example purposes only - **never store your private keys in a JavaScript file**
const privateKey = 'INSERT_PRIVATE_KEY';
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
// 3. Specify the Solidity version
solidity: '0.8.20',
networks: {
// 4. Add the network specification for your Tanssi EVM network
demo: {
url: 'https://services.tanssi-testnet.network/dancelight-2001/',
chainId: 5678, // Fill in the EVM ChainID for your Tanssi-powered network
accounts: [privateKey],
},
},
};
Remember
Não armazene chaves privadas em arquivos de código; use um gerenciador de segredos.
3) Crie o contrato:
mkdir -p contracts && touch contracts/MyTok.sol
MyTok.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyTok is ERC20, Ownable {
constructor() ERC20("MyToken", "MTK") Ownable(msg.sender) {
_mint(msg.sender, 50000 * 10 ** decimals());
}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}
4) Compile:
npx hardhat compile
5) Implante e registre o endereço:
mkdir -p scripts && touch scripts/deploy.js
npx hardhat run scripts/deploy.js --network demo
deploy.js
// scripts/deploy.js
const hre = require('hardhat');
require('@nomicfoundation/hardhat-ethers');
async function main() {
// Get ERC-20 contract
const MyTok = await hre.ethers.getContractFactory('MyTok');
// Define custom gas price and gas limit
// This is a temporary stopgap solution to a bug
const customGasPrice = 50000000000; // example for 50 gwei
const customGasLimit = 5000000; // example gas limit
// Deploy the contract providing a gas price and gas limit
const myTok = await MyTok.deploy({
gasPrice: customGasPrice,
gasLimit: customGasLimit,
});
// Wait for the deployment
await myTok.waitForDeployment();
console.log(`Contract deployed to ${myTok.target}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
6) Dispare algumas transferências para gerar eventos:
touch scripts/transactions.js
npx hardhat run scripts/transactions.js --network demo
transactions.js
// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// You can also run a script with `npx hardhat run <script>`. If you do that, Hardhat
// will compile your contracts, add the Hardhat Runtime Environment's members to the
// global scope, and execute the script.
const hre = require('hardhat');
async function main() {
// Get Contract ABI
const MyTok = await hre.ethers.getContractFactory('MyTok');
// Define custom gas price and gas limit
// Gas price is typically specified in 'wei' and gas limit is just a number
// You can use Ethers.js utility functions to convert from gwei or ether if needed
const customGasPrice = 50000000000; // example for 50 gwei
const customGasLimit = 5000000; // example gas limit
// Plug ABI to address
const myTok = await MyTok.attach('INSERT_CONTRACT_ADDRESS');
const value = 100000000000000000n;
let tx;
// Transfer to Baltathar
tx = await myTok.transfer(
'0x3Cd0A705a2DC65e5b1E1205896BaA2be8A07c6e0',
value,
{
gasPrice: customGasPrice,
gasLimit: customGasLimit,
}
);
await tx.wait();
console.log(`Transfer to Baltathar with TxHash ${tx.hash}`);
// Transfer to Charleth
tx = await myTok.transfer(
'0x798d4Ba9baf0064Ec19eB4F0a1a45785ae9D6DFc',
value,
{
gasPrice: customGasPrice,
gasLimit: customGasLimit,
}
);
await tx.wait();
console.log(`Transfer to Charleth with TxHash ${tx.hash}`);
// Transfer to Dorothy
tx = await myTok.transfer(
'0x773539d4Ac0e786233D90A233654ccEE26a613D9',
value,
{
gasPrice: customGasPrice,
gasLimit: customGasLimit,
}
);
await tx.wait();
console.log(`Transfer to Dorothy with TxHash ${tx.hash}`);
// Transfer to Ethan
tx = await myTok.transfer(
'0xFf64d3F6efE2317EE2807d223a0Bdc4c0c49dfDB',
value,
{
gasPrice: customGasPrice,
gasLimit: customGasLimit,
}
);
await tx.wait();
console.log(`Transfer to Ethan with TxHash ${tx.hash}`);
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Criar um projeto Squid¶
Instale o CLI e inicie o template EVM:
npm i -g @subsquid/cli@latest
sqd init tanssi-squid --template evm
cd tanssi-squid && npm ci
Configurar o indexador de transferências ERC-20¶
1) Definir esquema GraphQL:
schema.graphql
type Account @entity {
"Account address"
id: ID!
transfersFrom: [Transfer!] @derivedFrom(field: "from")
transfersTo: [Transfer!] @derivedFrom(field: "to")
}
type Transfer @entity {
id: ID!
blockNumber: Int!
timestamp: DateTime!
txHash: String!
from: Account!
to: Account!
amount: BigInt!
}
sqd codegen
2) Adicionar ABI genérica do ERC-20 em abi/erc20.json e gerar interfaces:
ERC-20 ABI
[
{
"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"
}
]
sqd typegen
3) Configurar processor.ts: fonte de dados, endereço do contrato, evento Transfer, intervalo de blocos e campos.
.setDataSource({
chain: {
url: assertNotNull('https://services.tanssi-testnet.network/dancelight-2001/'),
rateLimit: 300,
},
})
.addLog({
address: [contractAddress],
topic0: [erc20.events.Transfer.topic],
transaction: true,
})
.setBlockRange({ from: 632400 })
.setFields({
log: { topics: true, data: true },
transaction: { hash: true },
})
Imports necessários:
import { Store } from '@subsquid/typeorm-store';
import * as erc20 from './abi/erc20';
processor.ts
import { assertNotNull } from '@subsquid/util-internal';
import {
BlockHeader,
DataHandlerContext,
EvmBatchProcessor,
EvmBatchProcessorFields,
Log as _Log,
Transaction as _Transaction,
} from '@subsquid/evm-processor';
import { Store } from '@subsquid/typeorm-store';
import * as erc20 from './abi/erc20';
// Here you'll need to import the contract
export const contractAddress = 'INSERT_CONTRACT_ADDRESS'.toLowerCase();
export const processor = new EvmBatchProcessor()
.setDataSource({
chain: {
url: assertNotNull(
'https://services.tanssi-testnet.network/dancelight-2001'
),
rateLimit: 300,
},
})
.setFinalityConfirmation(10)
.setFields({
log: {
topics: true,
data: true,
},
transaction: {
hash: true,
},
})
.addLog({
address: [contractAddress],
topic0: [erc20.events.Transfer.topic],
transaction: true,
})
.setBlockRange({
from: INSERT_START_BLOCK, // Note the lack of quotes here
});
export type Fields = EvmBatchProcessorFields<typeof processor>;
export type Block = BlockHeader<Fields>;
export type Log = _Log<Fields>;
export type Transaction = _Transaction<Fields>;
Transformar e salvar os dados¶
Em main.ts, decodifique o evento Transfer, obtenha contas envolvidas, crie entidades e grave via TypeORM.
main.ts
import { In } from 'typeorm';
import { assertNotNull } from '@subsquid/evm-processor';
import { TypeormDatabase } from '@subsquid/typeorm-store';
import * as erc20 from './abi/erc20';
import { Account, Transfer } from './model';
import {
Block,
contractAddress,
Log,
Transaction,
processor,
} from './processor';
// 1. Iterate through all selected blocks and look for transfer events,
// storing the relevant events in an array of transfer events
processor.run(new TypeormDatabase({ supportHotBlocks: true }), async (ctx) => {
let transfers: TransferEvent[] = [];
for (let block of ctx.blocks) {
for (let log of block.logs) {
if (
log.address === contractAddress &&
log.topics[0] === erc20.events.Transfer.topic
) {
transfers.push(getTransfer(ctx, log));
}
}
}
await processTransfers(ctx, transfers);
});
// 2. Define an interface to hold the data from the transfer events
interface TransferEvent {
id: string;
block: Block;
transaction: Transaction;
from: string;
to: string;
amount: bigint;
}
// 3. Extract and decode ERC-20 transfer event data from a log entry
function getTransfer(ctx: any, log: Log): TransferEvent {
let event = erc20.events.Transfer.decode(log);
let from = event.from.toLowerCase();
let to = event.to.toLowerCase();
let amount = event.value;
let transaction = assertNotNull(log.transaction, `Missing transaction`);
return {
id: log.id,
block: log.block,
transaction,
from,
to,
amount,
};
}
// 4. Enrich and insert data into typeorm database
async function processTransfers(ctx: any, transfersData: TransferEvent[]) {
let accountIds = new Set<string>();
for (let t of transfersData) {
accountIds.add(t.from);
accountIds.add(t.to);
}
let accounts = await ctx.store
.findBy(Account, { id: In([...accountIds]) })
.then((q: any[]) => new Map(q.map((i: any) => [i.id, i])));
let transfers: Transfer[] = [];
for (let t of transfersData) {
let { id, block, transaction, amount } = t;
let from = getAccount(accounts, t.from);
let to = getAccount(accounts, t.to);
transfers.push(
new Transfer({
id,
blockNumber: block.height,
timestamp: new Date(block.timestamp),
txHash: transaction.hash,
from,
to,
amount,
})
);
}
await ctx.store.upsert(Array.from(accounts.values()));
await ctx.store.insert(transfers);
}
// 5. Helper function to get account object
function getAccount(m: Map<string, Account>, id: string): Account {
let acc = m.get(id);
if (acc == null) {
acc = new Account();
acc.id = id;
m.set(id, acc);
}
return acc;
}
Executar o indexador¶
sqd build
sqd up
sqd migration:generate
sqd migration:apply
sqd process
Consultar seu Squid¶
sqd serve
Acesse http://localhost:4350/graphql e faça queries, por exemplo:
Exemplo de query
query {
accounts {
id
transfersFrom {
id
blockNumber
timestamp
txHash
to {
id
}
amount
}
transfersTo {
id
blockNumber
timestamp
txHash
from {
id
}
amount
}
}
}
Depurar seu Squid¶
Habilite logs detalhados no .env:
SQD_DEBUG=*
Você pode adicionar logs em main.ts (veja exemplo com logging):
main-with-logging.ts
import { In } from 'typeorm';
import { assertNotNull } from '@subsquid/evm-processor';
import { TypeormDatabase } from '@subsquid/typeorm-store';
import * as erc20 from './abi/erc20';
import { Account, Transfer } from './model';
import {
Block,
contractAddress,
Log,
Transaction,
processor,
} from './processor';
processor.run(new TypeormDatabase({ supportHotBlocks: true }), async (ctx) => {
ctx.log.info('Processor started');
let transfers: TransferEvent[] = [];
ctx.log.info(`Processing ${ctx.blocks.length} blocks`);
for (let block of ctx.blocks) {
ctx.log.debug(`Processing block number ${block.header.height}`);
for (let log of block.logs) {
ctx.log.debug(`Processing log with address ${log.address}`);
if (
log.address === contractAddress &&
log.topics[0] === erc20.events.Transfer.topic
) {
ctx.log.info(`Transfer event found in block ${block.header.height}`);
transfers.push(getTransfer(ctx, log));
}
}
}
ctx.log.info(`Found ${transfers.length} transfers, processing...`);
await processTransfers(ctx, transfers);
ctx.log.info('Processor finished');
});
interface TransferEvent {
id: string;
block: Block;
transaction: Transaction;
from: string;
to: string;
amount: bigint;
}
function getTransfer(ctx: any, log: Log): TransferEvent {
let event = erc20.events.Transfer.decode(log);
let from = event.from.toLowerCase();
let to = event.to.toLowerCase();
let amount = event.value;
let transaction = assertNotNull(log.transaction, `Missing transaction`);
ctx.log.debug(
`Decoded transfer event: from ${from} to ${to} amount ${amount.toString()}`
);
return {
id: log.id,
block: log.block,
transaction,
from,
to,
amount,
};
}
async function processTransfers(ctx: any, transfersData: TransferEvent[]) {
ctx.log.info('Starting to process transfer data');
let accountIds = new Set<string>();
for (let t of transfersData) {
accountIds.add(t.from);
accountIds.add(t.to);
}
ctx.log.debug(`Fetching accounts for ${accountIds.size} addresses`);
let accounts = await ctx.store
.findBy(Account, { id: In([...accountIds]) })
.then((q: any[]) => new Map(q.map((i: any) => [i.id, i])));
ctx.log.info(
`Accounts fetched, processing ${transfersData.length} transfers`
);
let transfers: Transfer[] = [];
for (let t of transfersData) {
let { id, block, transaction, amount } = t;
let from = getAccount(accounts, t.from);
let to = getAccount(accounts, t.to);
transfers.push(
new Transfer({
id,
blockNumber: block.height,
timestamp: new Date(block.timestamp),
txHash: transaction.hash,
from,
to,
amount,
})
);
}
ctx.log.debug(`Upserting ${accounts.size} accounts`);
await ctx.store.upsert(Array.from(accounts.values()));
ctx.log.debug(`Inserting ${transfers.length} transfers`);
await ctx.store.insert(transfers);
ctx.log.info('Transfer data processing completed');
}
function getAccount(m: Map<string, Account>, id: string): Account {
let acc = m.get(id);
if (acc == null) {
acc = new Account();
acc.id = id;
m.set(id, acc);
}
return acc;
}
Erros comuns:
- Porta ocupada pelo banco: pare instâncias anteriores (
sqd down). ECONNREFUSED: suba o banco comsqd upantes de gerar/apply migrations.- Sem eventos detectados: confirme o endereço do contrato em minúsculas (
.toLowerCase()) e tópicos corretos.
| Criada: 9 de dezembro de 2025