Ir para o conteúdo

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:

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 com sqd up antes de gerar/apply migrations.
  • Sem eventos detectados: confirme o endereço do contrato em minúsculas (.toLowerCase()) e tópicos corretos.
As informações apresentadas aqui foram fornecidas por terceiros e estão disponíveis apenas para fins informativos gerais. A Tanssi não endossa nenhum projeto listado e descrito no Site de Documentação da Tanssi (https://docs.tanssi.network/). A Tanssi Foundation não garante a precisão, integridade ou utilidade dessas informações. Qualquer confiança depositada nelas é de sua exclusiva responsabilidade. A Tanssi Foundation se exime de toda responsabilidade decorrente de qualquer confiança que você ou qualquer outra pessoa possa ter em qualquer parte deste conteúdo. Todas as declarações e/ou opiniões expressas nesses materiais são de responsabilidade exclusiva da pessoa ou entidade que as fornece e não representam necessariamente a opinião da Tanssi Foundation. As informações aqui não devem ser interpretadas como aconselhamento profissional ou financeiro de qualquer tipo. Sempre busque orientação de um profissional devidamente qualificado em relação a qualquer assunto ou circunstância em particular. As informações aqui podem conter links ou integração com outros sites operados ou conteúdo fornecido por terceiros, e tais sites podem apontar para este site. A Tanssi Foundation não tem controle sobre esses sites ou seu conteúdo e não terá responsabilidade decorrente ou relacionada a eles. A existência de qualquer link não constitui endosso desses sites, de seu conteúdo ou de seus operadores. Esses links são fornecidos apenas para sua conveniência, e você isenta e exonera a Tanssi Foundation de qualquer responsabilidade decorrente do uso dessas informações ou das informações fornecidas por qualquer site ou serviço de terceiros.
Última atualização: 9 de dezembro de 2025
| Criada: 9 de dezembro de 2025