背景
上个月,我接手了一个 NFT 画廊项目的迭代开发。产品经理提了一个新需求:在项目首页,要展示我们平台核心 NFT 系列 CoolCats 的"持有者排行榜",也就是按持有数量从多到少列出前 20 名钱包地址,并且要能实时更新。
我的第一反应是:这还不简单?直接用 ethers.js 或者 viem 去读合约的 Transfer 事件,然后自己累加计算不就行了?于是,我迅速写了个脚本,遍历从合约创建以来的所有 Transfer 事件。结果,脚本跑了快十分钟才出结果,而且消耗的 RPC 调用次数多得吓人。在真实的前端页面里,用户不可能等十分钟,我们的免费 RPC 节点也扛不住这种查询频率。
这时我才意识到问题的核心:对于需要聚合、筛选历史链上数据的复杂查询,直接在客户端通过 RPC 调用是行不通的。性能和成本都是大问题。我需要一个索引好的、类数据库的查询服务。这就是我决定使用 The Graph 的原因------它可以把链上数据索引到可快速查询的数据库中,并通过 GraphQL API 暴露出来。
问题分析
我的目标是查询 CoolCats NFT 合约(假设地址为 0x...)的所有持有者及其持有数量。最初,我尝试在 The Graph 的托管服务上找有没有现成的子图(Subgraph)。可惜,虽然有类似项目的子图,但要么不是针对这个特定合约,要么索引的数据字段不符合我的要求(比如只记录了交易,没聚合持仓)。
所以,路只有一条:自己为这个 NFT 合约创建并部署一个子图。这听起来有点吓人,我之前只用过现成的 GraphQL 端点。但拆解一下,其实就三步:
- 定义数据模式(Schema) :明确我要索引和存储什么数据(例如
User实体,包含id(地址)和balance)。 - 编写映射脚本(Mapping) :用 AssemblyScript 写逻辑,告诉 The Graph 当监听到链上事件(如
Transfer)时,如何更新我定义的数据实体。 - 部署并查询:将子图部署到 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 端点
-
在 The Graph 托管服务网站创建账户和子图。
-
在本地终端登录并部署 :
bashgraph auth --product hosted-service <ACCESS_TOKEN> yarn deploy部署命令会编译 AssemblyScript、上传子图定义并开始同步链上数据。同步时间取决于合约历史事件的数量,可能需要几十分钟到几小时。
-
同步完成后,在托管服务的控制台,你会获得一个类似这样的 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.graphql 和 mapping.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; // 或者返回空数组,根据业务处理
}
}
踩坑记录
-
TypeError: e.plus is not a function:在映射文件中,我最初用了fromUser.balance -= 1。AssemblyScript 中BigInt必须使用其自身的方法.plus(),.minus(),.times(),.div()。改成fromUser.balance = fromUser.balance.minus(BigInt.fromI32(1))解决。 -
子图同步卡在某个区块不动 :部署后,子图同步状态一直停留在很早的区块。原因是我的映射函数
handleTransfer里出现了运行时错误(比如访问了空对象的属性),导致索引器在该区块崩溃。解决方法是去 The Graph 托管服务的日志页面查看错误信息,根据提示修复映射逻辑,然后重新部署。重要:修复后需要"重新部署"而非"重新同步",因为代码变更需要新的部署版本。 -
查询结果
balance为字符串 :GraphQL 中BigInt类型会以字符串形式返回。前端如果需要进行数值比较或计算,需要手动转换,例如Number(balance)或BigInt(balance)。注意 JavaScript 中大数的精度问题。 -
零地址处理不当导致数据错误 :我最初没有过滤零地址,为
0x000...也创建了User实体,导致排行榜上出现一个持有量巨大且奇怪的地址。通过添加isZeroAddress判断,并在查询时使用where: { balance_gt: "0" }过滤,解决了这个问题。
小结
这次实战让我彻底搞懂了如何从零开始,为一个智能合约创建 The Graph 子图,并集成到前端应用。核心收获是:将复杂的链上数据聚合逻辑转移到链下的索引服务中,是解决前端性能瓶颈的关键。现在,我们的 NFT 排行榜查询从十分钟变成了毫秒级。下一步,我可以探索更复杂的查询,比如分页、根据时间范围筛选持仓变化,甚至是将多个合约的数据关联到一个子图中进行联合查询。