使用Subsquid索引在Moonbeam上的NFT转账¶
作者:Massimo Luraschi
概览¶
Subsquid是一个全栈区块链索引SDK,其具有专门的数据湖(Archives),对大量历史链上数据的提取进行了优化。
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.api.onfinality.io/public-ws';
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进行了更改,所以我们需要删除现有的迁移并创建一个新的迁移,然后应用新的迁移。
为此,请执行以下步骤:
-
构建代码:
sqd build
-
确保您从一个空白的Postgres数据库开始操作。以下命令将在Docker中删除创建一个新的Postgres实例:
sqd down sqd up
-
生成新的迁移(这将清除所有旧迁移):
sqd migration:generate
-
应用迁移,以便在数据库中创建表格:
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的文档网站获取更详细的内容。
本教程仅用于教育目的。 因此,不应在生产环境中使用本教程中创建的任何合约或代码。
| Created: April 20, 2023