Skip to content

使用Subsquid索引在Moonbeam上的NFT转账

作者:Massimo Luraschi

概览

Subsquid是一个数据网络,开发者可以通过Subsquid的去中心化数据湖和开源SDK快速且高效地检索超过100种区块链的数据。

SDK提供高度可自定义的Extract-Transform-Load-Query栈,索引事件和交易时其索引速度高达每秒超过50,000区块以上。

Subsquid拥有原生和全面的以太坊虚拟机数据和Substrate数据支持。这允许开发者从任何Moonbeam网络提取链上数据,在单个项目中处理EVM日志和Substrate entities(事件、extrinsics和存储项),并使用单个GraphQL端点提供结果数据。通过Subsquid,可以根据EVM主题、智能合约地址和区块范围进行筛选。

本教程将介绍如何从在Moonriver上索引Moonsama转账的模板中创建Subsquid项目(也称为"squid"),并将其改为在Moonbeam网络上索引ERC-721 Token转账。这样一来,您将查看Transfer EVM事件主题。本教程也同样适用于Moonbase Alpha。

此处提供的信息仅供参考,由第三方提供。 Moonbeam文档网站(https://docs.moonbeam.network/)上列出和描述的任何项目与Moonbeam立场无关。

查看先决条件

要使Squid项目能够运行,您需要提前准备以下内容:

注意事项

此教程使用commands.json中定义的自定义脚本。此脚本会自动作为sqd子命令进行获取。

从模板中搭建一个项目

我们将从sqd init使用frontier-evm squid模板开始创建。其旨在索引Moonriver上部署的EVM智能合约,但是它也同样可以索引Substrate事件。要检索模板并安装依赖项,请运行以下命令:

sqd init moonbeam-tutorial --template frontier-evm
cd moonbeam-tutorial
npm ci

定义Entity Schema

接下来,我们要确保squid的数据schema定义了我们想要追踪的entities,具体如下:

  • Token转帐
  • Token所有权
  • 合约及其铸造的Token

幸运的是,EVM模板已经包含了一个schema文件,其定义了我们所需的entities:

type Token @entity {
  id: ID!
  owner: Owner
  uri: String
  transfers: [Transfer!]! @derivedFrom(field: "token")
  contract: Contract
}

type Owner @entity {
  id: ID!
  ownedTokens: [Token!]! @derivedFrom(field: "owner")
  balance: BigInt
}

type Contract @entity {
  id: ID!
  name: String
  symbol: String
  totalSupply: BigInt
  mintedTokens: [Token!]! @derivedFrom(field: "contract")
}

type Transfer @entity {
  id: ID!
  token: Token!
  from: Owner
  to: Owner
  timestamp: BigInt!
  block: Int!
  transactionHash: String!
}

schema定义中需要注意以下几个部分:

  • @entity - 表明此类型将被转换为持久保存在数据库中的ORM模型
  • @derivedFrom - 表示该字段不会保留在数据库中。而是派生自entity关系
  • type references (e.g. from: Owner) - 当用于entity类型时,他们建立了两个entity之间的关系

当schema更改时,都必须重新生成TypeScript entity类,我们可以使用squid-typeorm-codegen工具来实现。预先打包的commands.json已经包含codegen快捷方式,因此我们可以使用sqd来调用它:

sqd codegen

(重新)生成的entity类可以在src/model/generated中找到。

ABI定义和Wrapper

Subsquid维护工具,用于自动生成TypeScript类,以处理Substrate数据源(事件、extrinsics、存储项)。Runtime升级已考虑在内,并会自动检测。

类似的功能可以通过squid-evm-typegen用于EVM索引。它将根据合约的JSON ABI生成TypeScript模块,用于处理EVM日志和交易。

对于我们的squid,我们将需要一个用于满足合约接口的兼容ERC-721部分的模块。再次提醒,此模板的代码库已将其包含在里面,但是解释索引不同类型合约时需要完成的事项仍然很重要。

该过程使用模板中的sqd脚本,该脚本使用squid-evm-typegen为存储在abi文件夹中的JSON ABI生成Typescript facades。放入连接合约所需的任何ABI并运行以下命令:

sqd typegen

结果将存储在src/abi中。每个ABI文件将生成一个模块,此模块会包含用于筛选的常量和用于解码ABI中定义的EVM事件和函数的函数。

定义和绑定事件处理器

Subsquid SDK为用户提供SubstrateBatchProcessor。其实例连接指定区块链的Subsquid archives以访问链数据并进行自定义转换。索引从起始区块开始,并在达到最高值之后与最新区块保持一致。

SubstrateBatchProcessor公开函数以“订阅”指定数据,包括Substrate事件、extrinsics、存储项、或者EVM的日志和交易。然后,通过调用.run()函数开始实际数据处理。这将开始为配置中指定数据的batches生成对Archive的请求,并在每次由Archive返回一个batch时触发回调函数或batch handler(作为第二个参数传递给.run())。

回调函数表达了所有的映射逻辑。这是应该实现链数据解码的地方,也是应该编写将处理后数据保存在数据库中的代码的地方。

管理EVM合约

在开始定义squid的映射逻辑之前,我们要先重写src/contracts.ts实用程序模块以管理涉及的EVM合约。这将导出:

  • Gromlins合约的地址
  • 一个创建和保存Contract entity实例至数据库的函数
  • 一个返回Contract实例(已经存在或新创建的entity)的函数。第一次调用该函数时,它会验证Contract是否已经存在,反之,它将调用第一个函数,并缓存结果,因此在后续调用中将会返回缓存版本

以下是完整的文件内容:

// src/contract.ts
import { Contract as ContractAPI } from './abi/erc721';
import { BigNumber } from 'ethers';
import { Context } from './processor';
import { Contract } from './model';

export const contractAddress = 'wss://moonbeam.public.blastapi.io';

export async function createContractEntity(ctx: Context): Promise<Contract> {
  const lastBlock = ctx.blocks[ctx.blocks.length - 1].header;
  const contractAPI = new ContractAPI(
    { ...ctx, block: lastBlock },
    contractAddress
  );
  let name = '',
    symbol = '',
    totalSupply = BigNumber.from(0);
  ctx.log.info('Creating new Contract model instance');
  try {
    name = await contractAPI.name();
    symbol = await contractAPI.symbol();
    totalSupply = await contractAPI.totalSupply();
  } catch (error) {
    ctx.log.warn(
      `[API] Error while fetching Contract metadata for address ${contractAddress}`
    );
    if (error instanceof Error) {
      ctx.log.warn(`${error.message}`);
    }
  }
  return new Contract({
    id: contractAddress,
    name: name,
    symbol: symbol,
    totalSupply: totalSupply.toBigInt(),
  });
}

let contractEntity: Contract | undefined;

export async function getContractEntity(ctx: Context): Promise<Contract> {
  if (contractEntity == null) {
    contractEntity = await ctx.store.get(Contract, contractAddress);
    if (contractEntity == null) {
      contractEntity = await createContractEntity(ctx);
      await ctx.store.insert(contractEntity);
    }
  }
  return contractEntity;
}

你可能会收到Context变量未导出的警告,但是无需担心,我们将从在下一部分中从src/processor.ts文件导出。

注意事项

createContractEntity函数通过链的RPC端点访问合约的state。这会稍微减慢索引的速度,但这是访问此数据的唯一方法。您可以在此文档找到获取状态的更多信息。

配置处理器(Processor)并附加处理程序(Handler)

src/processor.ts文件是squid实例化处理器(本示例中为SubstrateBatchProcessor)、配置它并添加处理函数的地方。

此处只需要调整模板代码以处理Gromlins合约并将处理器设置为使用从archive registry中检索到的moonbeam archive URL。

要在Moonbeam或Moonriver网络上测试本指南中的示例,您可以从受支持的网络端点提供商之一获取您自己的端点和API密钥。

此教程也同样适用于Moonriver或Moonbase Alpha,但请确保将数据源更新为正确的网络:

processor.setDataSource({
  chain: process.env.RPC_ENDPOINT, // TODO: Add the endpoint to your .env file
  archive: lookupArchive('moonbeam', { type: 'Substrate' }),
});
processor.setDataSource({
  chain: process.env.RPC_ENDPOINT, // TODO: Add the endpoint to your .env file
  archive: lookupArchive('moonriver', { type: 'Substrate' }),
});
processor.setDataSource({
  chain: process.env.RPC_ENDPOINT, // TODO: Add the endpoint to your .env file
  archive: lookupArchive('moonbase', { type: 'Substrate' }),
});

注意事项

lookupArchive函数用于查询archive registry并在给定网络名称的情况下生成archive地址。注意网络名称需为小写。

您也需要修改Context类型,使其可以导出并用于src/contract.ts文件中。

export type Context = BatchContext<Store, Item>;

以下是最终结果:

// src/processor.ts
import { lookupArchive } from '@subsquid/archive-registry';
import { Store, TypeormDatabase } from '@subsquid/typeorm-store';
import {
  BatchContext,
  BatchProcessorItem,
  EvmLogEvent,
  SubstrateBatchProcessor,
  SubstrateBlock,
} from '@subsquid/substrate-processor';
import { In } from 'typeorm';
import { ethers } from 'ethers';
import { contractAddress, getContractEntity } from './contract';
import { Owner, Token, Transfer } from './model';
import * as erc721 from './abi/erc721';
import { EvmLog, getEvmLog } from '@subsquid/frontier';

const database = new TypeormDatabase();

const processor = new SubstrateBatchProcessor()
  .setDataSource({
    // FIXME: set RPC_ENDPOINT secret when deploying to Aquarium
    //        See https://docs.subsquid.io/deploy-squid/env-variables/
    chain: process.env.RPC_ENDPOINT || 'wss://wss.api.moonbeam.network',
    archive: lookupArchive('moonbeam', { type: 'Substrate' }),
  })
  .addEvmLog(contractAddress, {
    filter: [[erc721.events.Transfer.topic]],
  });

type Item = BatchProcessorItem<typeof processor>;
export type Context = BatchContext<Store, Item>;

processor.run(database, async (ctx) => {
  const transfersData: TransferData[] = [];

  for (const block of ctx.blocks) {
    for (const item of block.items) {
      if (item.name === 'EVM.Log') {
        // EVM log extracted from the substrate event
        const evmLog = getEvmLog(ctx, item.event);
        const transfer = handleTransfer(block.header, item.event, evmLog);
        transfersData.push(transfer);
      }
    }
  }

  await saveTransfers(ctx, transfersData);
});

type TransferData = {
  id: string;
  from: string;
  to: string;
  token: ethers.BigNumber;
  timestamp: bigint;
  block: number;
  transactionHash: string;
};

function handleTransfer(
  block: SubstrateBlock,
  event: EvmLogEvent,
  evmLog: EvmLog
): TransferData {
  const { from, to, tokenId } = erc721.events.Transfer.decode(evmLog);

  const transfer: TransferData = {
    id: event.id,
    token: tokenId,
    from,
    to,
    timestamp: BigInt(block.timestamp),
    block: block.height,
    transactionHash: event.evmTxHash,
  };

  return transfer;
}

async function saveTransfers(ctx: Context, transfersData: TransferData[]) {
  const tokensIds: Set<string> = new Set();
  const ownersIds: Set<string> = new Set();

  for (const transferData of transfersData) {
    tokensIds.add(transferData.token.toString());
    ownersIds.add(transferData.from);
    ownersIds.add(transferData.to);
  }

  const transfers: Set<Transfer> = new Set();

  const tokens: Map<string, Token> = new Map(
    (await ctx.store.findBy(Token, { id: In([...tokensIds]) })).map((token) => [
      token.id,
      token,
    ])
  );

  const owners: Map<string, Owner> = new Map(
    (await ctx.store.findBy(Owner, { id: In([...ownersIds]) })).map((owner) => [
      owner.id,
      owner,
    ])
  );

  if (process.env.RPC_ENDPOINT == undefined) {
    ctx.log.warn(`RPC_ENDPOINT env variable is not set`);
  }

  for (const transferData of transfersData) {
    const contract = new erc721.Contract(
      ctx,
      { height: transferData.block },
      contractAddress
    );

    let from = owners.get(transferData.from);
    if (from == null) {
      from = new Owner({ id: transferData.from, balance: 0n });
      owners.set(from.id, from);
    }

    let to = owners.get(transferData.to);
    if (to == null) {
      to = new Owner({ id: transferData.to, balance: 0n });
      owners.set(to.id, to);
    }

    const tokenId = transferData.token.toString();

    let token = tokens.get(tokenId);
    if (token == null) {
      token = new Token({
        id: tokenId,
        // FIXME: use multicall here to batch
        //        contract calls and speed up indexing
        uri: await contract.tokenURI(transferData.token),
        contract: await getContractEntity(ctx),
      });
      tokens.set(token.id, token);
      ctx.log.info(`Upserted NFT: ${token.id}`);
    }
    token.owner = to;

    const { id, block, transactionHash, timestamp } = transferData;

    const transfer = new Transfer({
      id,
      block,
      timestamp,
      transactionHash,
      from,
      to,
      token,
    });

    transfers.add(transfer);
  }

  await ctx.store.save([...owners.values()]);
  await ctx.store.save([...tokens.values()]);
  await ctx.store.save([...transfers]);
}

注意事项

contract.tokenURI调用通过链RPC端点访问合约的state。这会稍微减慢索引的速度,但这是访问此数据的唯一方法。您可以在此文档找到获取状态的更多信息。

注意事项

此代码期望在RPC_ENDPOINT环境变量中找到适用于Moonbeam RPC端点的URL。如果您准备将squid部署至Moonbeam时,您可以在.env文件和Aquarium secrets中设置。我们已经使用wss://wss.api.moonbeam.network公共端点测试了代码;如果您想用于生产环境,我们建议您使用私有端点

启动并设置数据库

当本地运行项目时,可以使用模板附带的docker-compose.yml文件启动PostgreSQL容器。为此,请在您的终端运行sqd up

Squid项目通过ORM abstraction自动管理数据库连接和schema。在此方式中,schema通过迁移文档进行管理。因为我们对schema进行了更改,所以我们需要删除现有的迁移并创建一个新的迁移,然后应用新的迁移。

为此,请执行以下步骤:

  1. 构建代码:

    sqd build
    
  2. 确保您从一个空白的Postgres数据库开始操作。以下命令将在Docker中删除创建一个新的Postgres实例:

    sqd down
    sqd up
    
  3. 生成新的迁移(这将清除所有旧迁移):

    sqd migration:generate
    
  4. 应用迁移,以便在数据库中创建表格:

    sqd migration:apply
    

启动项目

运行以下命令启动处理器(这将阻挡当前的终端):

sqd process

最后,在另一个终端窗口,启动GraphQL服务器:

sqd serve

前往localhost:4350/graphql来访问GraphiQL控制台。在此窗口中,您可以执行类似这样的查询,找到拥有余额量最大的账户所有者:

query MyQuery {
  owners(limit: 10, where: {}, orderBy: balance_DESC) {
    balance
    id
  }
}

或者找出给定所有者的Token持有数量:

query MyQuery {
  tokens(where: {owner: {id_eq: "0x5274a86d39fd6db8e73d0ab6d7d5419c1bf593f8"}}) {
    uri
    contract {
      id
      name
      symbol
      totalSupply
    }
  }
}

您可以根据自己的需求,尝试各种查询。

发布项目

Subsquid提供SaaS解决方案来管理由社区创建的项目。所有的模板都附带一个名为squid.yml的部署manifest文件,该文件可以与Squid CLI命令sqd deploy结合使用。

请查阅Subquid文档网站的部署Squid部分获取更多信息。

示例项目代码库

您可以在GitHub的Subsquid示例部分查看本教程使用的模板,以及其他示例代码库。

如果您对本教程中未完整解释的某些方面感到好奇,请前往Subsquid的文档网站获取更详细的内容。

本教程仅用于教育目的。 因此,不应在生产环境中使用本教程中创建的任何合约或代码。

本网站的所有信息由第三方提供,仅供参考之用。Moonbeam文档网站(https://docs.moonbeam.network/)上列出和描述的任何项目与Moonbeam立场无关。Moonbeam Foundation不保证网站信息的准确性、完整性或真实性。如使用或依赖本网站信息,需自行承担相关风险,Moonbeam Foundation不承担任何责任和义务。这些材料的所有陈述和/或意见由提供方个人或实体负责,与Moonbeam Foundation立场无关,概不构成任何投资建议。对于任何特定事项或情况,应寻求专业权威人士的建议。此处的信息可能会包含或链接至第三方提供的信息与/或第三方服务(包括任何第三方网站等)。这类链接网站不受Moonbeam Foundation控制。Moonbeam Foundation对此类链接网站的内容(包括此类链接网站上包含的任何信息或资料)概不负责也不认可。这些链接内容仅为方便访客而提供,Moonbeam Foundation对因您使用此信息或任何第三方网站或服务提供的信息而产生的所有责任概不负责。
Last update: January 23, 2024
| Created: April 20, 2023