从零到一:在 React 前端中集成 The Graph 查询 NFT 持有者数据实战

背景

上个月,我接手了一个 NFT 画廊项目的迭代开发。产品经理提了一个新需求:在项目首页,要展示我们平台核心 NFT 系列 CoolCats 的"持有者排行榜",也就是按持有数量从多到少列出前 20 名钱包地址,并且要能实时更新。

我的第一反应是:这还不简单?直接用 ethers.js 或者 viem 去读合约的 Transfer 事件,然后自己累加计算不就行了?于是,我迅速写了个脚本,遍历从合约创建以来的所有 Transfer 事件。结果,脚本跑了快十分钟才出结果,而且消耗的 RPC 调用次数多得吓人。在真实的前端页面里,用户不可能等十分钟,我们的免费 RPC 节点也扛不住这种查询频率。

这时我才意识到问题的核心:对于需要聚合、筛选历史链上数据的复杂查询,直接在客户端通过 RPC 调用是行不通的。性能和成本都是大问题。我需要一个索引好的、类数据库的查询服务。这就是我决定使用 The Graph 的原因------它可以把链上数据索引到可快速查询的数据库中,并通过 GraphQL API 暴露出来。

问题分析

我的目标是查询 CoolCats NFT 合约(假设地址为 0x...)的所有持有者及其持有数量。最初,我尝试在 The Graph 的托管服务上找有没有现成的子图(Subgraph)。可惜,虽然有类似项目的子图,但要么不是针对这个特定合约,要么索引的数据字段不符合我的要求(比如只记录了交易,没聚合持仓)。

所以,路只有一条:自己为这个 NFT 合约创建并部署一个子图。这听起来有点吓人,我之前只用过现成的 GraphQL 端点。但拆解一下,其实就三步:

  1. 定义数据模式(Schema) :明确我要索引和存储什么数据(例如 User 实体,包含 id(地址)和 balance)。
  2. 编写映射脚本(Mapping) :用 AssemblyScript 写逻辑,告诉 The Graph 当监听到链上事件(如 Transfer)时,如何更新我定义的数据实体。
  3. 部署并查询:将子图部署到 The Graph 的托管服务或去中心化网络,然后从前端用 GraphQL 查询。

排查过程里,我卡住的第一个点是:如何准确处理 Transfer 事件,来正确增减用户的持仓?这里逻辑必须严谨,否则数据全错。比如,从"零地址" (0x000...) 转出代表铸造(Mint),接收方余额增加;转入"零地址"代表销毁(Burn),发送方余额减少。普通转账则是发送方减,接收方加。

核心实现

第一步:搭建子图项目与环境

首先,需要安装 Graph CLI。

bash 复制代码
npm install -g @graphprotocol/graph-cli
# 或者用 yarn global add @graphprotocol/graph-cli

然后,初始化一个子图项目。这里我选择从已有的 NFT 标准合约 ABI 开始,因为 CoolCats 遵循 ERC-721。

bash 复制代码
graph init --product hosted-service \
  --from-contract <CONTRACT_ADDRESS> \
  --network mainnet \
  --abi ./path/to/ERC721ABI.json \
  <GITHUB_USER>/<SUBGRAPH_NAME>

注意这个细节--product hosted-service 表示部署到 The Graph 的托管服务(免费,适合开发测试)。如果想部署到去中心化网络,需要用 --product subgraph-studio--from-contract 可以自动生成一些基础代码,但合约地址需要已经在 Etherscan 验证,否则 ABI 获取可能失败。稳妥起见,我直接用了本地保存的 ABI 文件。

初始化后,会生成一个标准的项目结构,关键文件是:

  • subgraph.yaml:子图清单,定义了数据源、合约地址、网络、映射文件等。
  • schema.graphql:数据模式定义文件。
  • src/mapping.ts:数据映射逻辑的入口文件。

第二步:定义数据模式(Schema)

schema.graphql 中,我定义了两个实体(Entity):

graphql 复制代码
type User @entity {
  id: ID! # 用户的钱包地址,作为唯一ID
  balance: BigInt! # 当前持有的 NFT 数量
  tokens: [Token!]! @derivedFrom(field: "owner") # 关联持有的具体Token(可选)
}

type Token @entity {
  id: ID! # 格式为 "合约地址-tokenId"
  tokenId: BigInt!
  owner: User!
}

我的主要目标是排行榜,所以 User 实体是核心。balance 字段用于快速排序和查询。Token 实体是可选的,如果你还需要追踪每个 NFT 的归属,可以加上。@derivedFrom 表示 User.tokens 字段是从 Token.owner 字段反向派生出来的,不需要在映射中手动维护,这是一个非常方便的特性。

这里有个坑ID 类型在 GraphQL 中是字符串,但 The Graph 要求 id 字段必须唯一。对于 User,我直接用钱包地址(小写)作为 id。对于 Token,我组合了合约地址和 tokenId 来保证唯一性。

第三步:编写映射逻辑(Mapping)

这是最核心也最容易出错的部分。映射逻辑写在 src/mapping.ts 里,用的是 AssemblyScript(TypeScript 的子集)。

首先,要处理 Transfer 事件。我需要更新发送方(from)和接收方(to)的 User 实体的 balance

typescript 复制代码
import { Transfer } from "../generated/CoolCats/CoolCats";
import { User, Token } from "../generated/schema";
import { Address } from "@graphprotocol/graph-ts";

export function handleTransfer(event: Transfer): void {
  // 1. 确保发送方和接收方的 User 实体存在
  let fromAddress = event.params.from.toHexString();
  let toAddress = event.params.to.toHexString();
  let tokenId = event.params.tokenId;

  let fromUser = User.load(fromAddress);
  let toUser = User.load(toAddress);

  // 处理发送方(如果不是零地址)
  if (!isZeroAddress(fromAddress)) {
    if (fromUser == null) {
      // 理论上不应该发生,但创建以防万一
      fromUser = new User(fromAddress);
      fromUser.balance = BigInt.fromI32(0);
    }
    // 发送方减少一个NFT
    fromUser.balance = fromUser.balance.minus(BigInt.fromI32(1));
    fromUser.save();
  }

  // 处理接收方(如果不是零地址)
  if (!isZeroAddress(toAddress)) {
    if (toUser == null) {
      toUser = new User(toAddress);
      toUser.balance = BigInt.fromI32(0);
    }
    // 接收方增加一个NFT
    toUser.balance = toUser.balance.plus(BigInt.fromI32(1));
    toUser.save();
  }

  // 2. 更新 Token 实体的所有者(可选,如果你定义了Token实体)
  let tokenCompositeId = event.address.toHexString() + '-' + tokenId.toString();
  let token = Token.load(tokenCompositeId);
  if (token == null) {
    token = new Token(tokenCompositeId);
    token.tokenId = tokenId;
  }
  // 如果转入零地址,代表销毁,owner可以指向一个"销毁地址"实体或清空。这里简单指向零地址的User。
  token.owner = isZeroAddress(toAddress) ? toAddress : toAddress;
  token.save();
}

function isZeroAddress(address: string): boolean {
  return address == '0x0000000000000000000000000000000000000000';
}

踩坑预警BigInt 运算必须使用 The Graph 提供的 .plus(), .minus() 方法,不能用 +- 操作符,否则编译会报错。另外,一定要小心零地址的处理,它代表资产铸造或销毁,不应该为其创建 User 实体。

第四步:部署子图并获取 API 端点

  1. 在 The Graph 托管服务网站创建账户和子图

  2. 在本地终端登录并部署

    bash 复制代码
    graph auth --product hosted-service <ACCESS_TOKEN>
    yarn deploy

    部署命令会编译 AssemblyScript、上传子图定义并开始同步链上数据。同步时间取决于合约历史事件的数量,可能需要几十分钟到几小时。

  3. 同步完成后,在托管服务的控制台,你会获得一个类似这样的 GraphQL API 端点: https://api.thegraph.com/subgraphs/name/你的用户名/你的子图名称

第五步:在前端 React 项目中查询

现在,就可以在前端用任何 GraphQL 客户端查询数据了。我选择使用 graphql-request,因为它轻量简单。

bash 复制代码
npm install graphql-request graphql

然后,创建一个服务文件 src/services/theGraph.ts

typescript 复制代码
import { GraphQLClient, gql } from 'graphql-request';

// 替换成你部署后的真实端点
const GRAPHQL_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/your-username/coolcats-holders';

const client = new GraphQLClient(GRAPHQL_ENDPOINT);

export interface UserRank {
  id: string; // 钱包地址
  balance: string; // 持仓数量,GraphQL 返回的是字符串
}

export async function fetchTopHolders(limit: number = 20): Promise<UserRank[]> {
  const query = gql`
    query GetTopHolders($first: Int!) {
      users(
        first: $first,
        orderBy: balance,
        orderDirection: desc,
        where: { balance_gt: 0 } # 只查询持仓大于0的
      ) {
        id
        balance
      }
    }
  `;

  try {
    const data: any = await client.request(query, { first: limit });
    // 转换类型,balance 从字符串转为数字(如果需要)
    return data.users.map((user: any) => ({
      id: user.id,
      balance: user.balance,
    }));
  } catch (error) {
    console.error('Error fetching data from The Graph:', error);
    return [];
  }
}

最后,在 React 组件中使用:

tsx 复制代码
import React, { useEffect, useState } from 'react';
import { fetchTopHolders, UserRank } from './services/theGraph';

function HolderLeaderboard() {
  const [holders, setHolders] = useState<UserRank[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const loadData = async () => {
      setLoading(true);
      const data = await fetchTopHolders(20);
      setHolders(data);
      setLoading(false);
    };
    loadData();
  }, []);

  if (loading) return <div>Loading leaderboard...</div>;

  return (
    <div>
      <h2>CoolCats Top Holders</h2>
      <table>
        <thead>
          <tr>
            <th>Rank</th>
            <th>Address</th>
            <th>Balance</th>
          </tr>
        </thead>
        <tbody>
          {holders.map((holder, index) => (
            <tr key={holder.id}>
              <td>{index + 1}</td>
              <td>{`${holder.id.slice(0, 6)}...${holder.id.slice(-4)}`}</td>
              <td>{holder.balance}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export default HolderLeaderboard;

完整代码

由于子图项目文件较多,这里提供最核心的 schema.graphqlmapping.ts 的完整代码,以及前端查询服务的代码。

1. 子图 schema.graphql (完整)

graphql 复制代码
type User @entity {
  id: ID! # 用户钱包地址
  balance: BigInt! # 持仓数量
  tokens: [Token!]! @derivedFrom(field: "owner") # 持有的NFT列表
}

type Token @entity {
  id: ID! # 合约地址-tokenId
  tokenId: BigInt!
  owner: User! # NFT当前所有者
}

2. 子图 src/mapping.ts (完整)

typescript 复制代码
import { Transfer } from "../generated/CoolCats/CoolCats";
import { User, Token } from "../generated/schema";
import { BigInt } from "@graphprotocol/graph-ts";

export function handleTransfer(event: Transfer): void {
  let from = event.params.from;
  let to = event.params.to;
  let tokenId = event.params.tokenId;
  let fromAddress = from.toHexString();
  let toAddress = to.toHexString();

  // 更新发送方余额 (非零地址)
  if (!isZeroAddress(fromAddress)) {
    let fromUser = User.load(fromAddress);
    if (fromUser == null) {
      // 防御性创建,理论上在第一次转出时应该已存在
      fromUser = new User(fromAddress);
      fromUser.balance = BigInt.fromI32(0);
    }
    fromUser.balance = fromUser.balance.minus(BigInt.fromI32(1));
    // 如果余额减到0,可以选择保留或删除实体。排行榜查询时用 where balance_gt: 0 过滤即可。
    fromUser.save();
  }

  // 更新接收方余额 (非零地址)
  if (!isZeroAddress(toAddress)) {
    let toUser = User.load(toAddress);
    if (toUser == null) {
      toUser = new User(toAddress);
      toUser.balance = BigInt.fromI32(0);
    }
    toUser.balance = toUser.balance.plus(BigInt.fromI32(1));
    toUser.save();
  }

  // 更新Token所有者信息
  let tokenCompositeId = event.address.toHexString() + '-' + tokenId.toString();
  let token = Token.load(tokenCompositeId);
  if (token == null) {
    token = new Token(tokenCompositeId);
    token.tokenId = tokenId;
  }
  // 注意:如果转入零地址(销毁),owner指向零地址的User实体(不存在或余额为0)
  token.owner = toAddress; // 直接存储地址字符串作为关联ID
  token.save();
}

function isZeroAddress(address: string): boolean {
  return address == '0x0000000000000000000000000000000000000000';
}

3. 前端查询服务 src/services/theGraph.ts (完整)

typescript 复制代码
import { GraphQLClient, gql } from 'graphql-request';

const GRAPHQL_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/your-username/coolcats-subgraph';

const client = new GraphQLClient(GRAPHQL_ENDPOINT);

export interface UserRank {
  id: string;
  balance: string;
}

export async function fetchTopHolders(limit: number = 20): Promise<UserRank[]> {
  const query = gql`
    query GetTopHolders($first: Int!) {
      users(
        first: $first,
        orderBy: balance,
        orderDirection: desc,
        where: { balance_gt: "0" }
      ) {
        id
        balance
      }
    }
  `;

  try {
    const data: any = await client.request(query, { first: limit });
    return data.users;
  } catch (error) {
    console.error('Error fetching from The Graph:', error);
    throw error; // 或者返回空数组,根据业务处理
  }
}

踩坑记录

  1. TypeError: e.plus is not a function :在映射文件中,我最初用了 fromUser.balance -= 1。AssemblyScript 中 BigInt 必须使用其自身的方法 .plus(), .minus(), .times(), .div()。改成 fromUser.balance = fromUser.balance.minus(BigInt.fromI32(1)) 解决。

  2. 子图同步卡在某个区块不动 :部署后,子图同步状态一直停留在很早的区块。原因是我的映射函数 handleTransfer 里出现了运行时错误(比如访问了空对象的属性),导致索引器在该区块崩溃。解决方法是去 The Graph 托管服务的日志页面查看错误信息,根据提示修复映射逻辑,然后重新部署。重要:修复后需要"重新部署"而非"重新同步",因为代码变更需要新的部署版本。

  3. 查询结果 balance 为字符串 :GraphQL 中 BigInt 类型会以字符串形式返回。前端如果需要进行数值比较或计算,需要手动转换,例如 Number(balance)BigInt(balance)。注意 JavaScript 中大数的精度问题。

  4. 零地址处理不当导致数据错误 :我最初没有过滤零地址,为 0x000... 也创建了 User 实体,导致排行榜上出现一个持有量巨大且奇怪的地址。通过添加 isZeroAddress 判断,并在查询时使用 where: { balance_gt: "0" } 过滤,解决了这个问题。

小结

这次实战让我彻底搞懂了如何从零开始,为一个智能合约创建 The Graph 子图,并集成到前端应用。核心收获是:将复杂的链上数据聚合逻辑转移到链下的索引服务中,是解决前端性能瓶颈的关键。现在,我们的 NFT 排行榜查询从十分钟变成了毫秒级。下一步,我可以探索更复杂的查询,比如分页、根据时间范围筛选持仓变化,甚至是将多个合约的数据关联到一个子图中进行联合查询。

相关推荐
山西茄子2 小时前
GstAggregator的aggregate
开发语言·前端·javascript·gstreamer
Sailing2 小时前
🚨别再滥用 useEffect 了!90% React Bug 的根源就在这
前端·javascript·面试
河马老师2 小时前
写这需求快崩溃了,幸好我会装饰器模式
前端·javascript·面试
未来转换2 小时前
Python-web开发之Flask框架入门
前端·python·flask
用户5757303346242 小时前
🚀 拒绝“CSS 命名困难症”!手把手带你用 Tailwind CSS 搓一个“高颜值”登录页
前端
文静小土豆2 小时前
标签和选择器(Label和 Selector)
linux·前端
wuhen_n2 小时前
《Vue3+TS+Vite 高效编程与优化实践》专栏收尾
前端·javascript·vue.js
kevinten102 小时前
折腾三个月,我把摩旅路线和 AI 搞在一起了
前端
偷光2 小时前
大模型核心技术概述:Token、Prompt、Tool与Agent的关系详解
前端·ai·prompt·ai编程