Web3 中级实战:基于 Solidity 与 The Graph 构建可查询的链上积分系统
很多团队第一次做“链上积分”时,直觉是:写一个 Solidity 合约,记录用户积分增减,然后前端直接查合约。
但真到业务跑起来,很快就会发现几个现实问题:
- 链上原始数据不适合复杂查询
- 排行榜、明细列表、按时间过滤 这些需求,用 RPC 直接扫事件会越来越慢
- 前端自己聚合数据,开发成本高,重放逻辑还容易出错
- 积分规则一旦变复杂,比如签到、邀请、任务奖励、管理员纠偏,数据模型会迅速失控
这篇文章我想从“架构落地”的角度,带你做一个可查询的链上积分系统:
用 Solidity 负责可信记账,用 The Graph 负责可查询索引。这样既保留链上可验证性,又能让业务方像查数据库一样查积分明细和排行榜。
背景与问题
为什么“只写合约”不够
积分系统和代币系统不完全一样。很多业务场景并不需要可转账资产,而是需要:
- 给某用户增加积分
- 扣减积分
- 记录每一笔积分变动的原因
- 查询某地址当前总积分
- 查询某用户最近 20 条积分流水
- 做排行榜
- 按任务类型或时间段分析积分发放情况
如果只依赖链上状态变量,通常会遇到两个问题:
-
状态好查,历史难查
比如balances[address]能读到当前积分,但“这 100 分怎么来的”需要扫事件。 -
RPC 擅长读点,不擅长读面
链节点适合读取某个状态,不适合高频做分页、排序、聚合分析。
这时,The Graph 的价值就很明确了:
把合约事件转成结构化实体,提供 GraphQL 查询能力。
方案概览
我们先看整体架构。
flowchart LR
A[Admin / Backend] -->|发交易| B[PointsLedger.sol]
B -->|emit PointsChanged| C[Blockchain]
C -->|event indexing| D[The Graph Subgraph]
D --> E[(GraphQL API)]
F[Frontend / Dashboard] -->|query| E
F -->|钱包签名/读链| C
这个架构里,职责分得很清楚:
- Solidity 合约
- 记录积分余额
- 控制谁能发放/扣减积分
- 产出标准化事件
- The Graph
- 监听事件
- 维护用户实体、积分变动流水实体
- 提供复杂查询
- 前端 / 管理后台
- 发起积分操作
- 查询用户积分、流水、排行榜
方案对比与取舍分析
在设计阶段,我通常会把几个可选方案摆出来,不然容易在实现过程中反复推翻。
方案 A:纯链上状态 + 前端自己扫事件
优点
- 组件少,概念简单
- 不需要额外部署索引层
缺点
- 查询成本高
- 分页、排序、时间过滤麻烦
- 多端复用困难
- 前端逻辑变重,容易出现重放不一致
适合:PoC、小规模 demo。
方案 B:链上状态 + 后端自建索引服务
优点
- 灵活,能做复杂聚合
- 可完全控制数据模型
缺点
- 自己维护索引程序、数据库、重放逻辑
- 处理链重组、断点续跑、幂等都需要自己做
适合:强定制业务、大规模数据分析平台。
方案 C:链上状态 + The Graph
优点
- 开发效率高
- 事件到实体的映射天然适合积分流水
- GraphQL 查询体验好
- 比纯前端扫链更稳定
缺点
- 查询能力仍基于预先建模
- 某些复杂聚合不如自建分析库灵活
- 要理解 subgraph schema、mapping、部署流程
适合:
大多数需要“可信记账 + 可查询业务视图”的 Web3 应用。
这篇文章采用的就是 方案 C。
核心原理
积分系统最小可用模型
一个可维护的链上积分系统,我建议至少有这几层数据:
- 账户总积分
- 每次积分变动流水
- 变动原因或业务类型
- 操作者信息
在链上,我们只保留最关键的可信状态:
balanceOf[user]- 权限控制
- 事件日志
在索引层,构建可查询实体:
UserPointsTransaction
事件驱动的数据流
sequenceDiagram
participant Admin as 管理员/后端
participant Contract as PointsLedger
participant Chain as 区块链
participant Graph as The Graph
participant UI as 前端
Admin->>Contract: award(user, amount, reason)
Contract->>Chain: 写入状态 balanceOf[user]
Contract->>Chain: emit PointsChanged(...)
Graph->>Chain: 监听事件
Graph->>Graph: 更新 User 与 Transaction 实体
UI->>Graph: GraphQL 查询用户积分/流水
Graph-->>UI: 返回结构化数据
你可以把它理解成:
- 合约像“总账本”
- The Graph 像“读优化后的明细库”
- 前端不要直接拿账本做分析报表
为什么事件设计比你想的更重要
很多同学写合约时事件随手定义,后面做索引才发现不够用。
一个好的积分事件,至少应该包含:
- 用户地址
- 操作者地址
- 变动数值
- 变动后余额
- 动作类型
- 业务原因
- 时间、区块、交易哈希(这些链上天然可取)
这样索引层就不用猜测“这笔积分到底是什么”。
实战代码(可运行)
下面我们做一套最小但完整的实现:
- 一个 Solidity 合约:
PointsLedger.sol - 一个 The Graph subgraph:
schema.graphqlsubgraph.yamlsrc/mapping.ts
说明:为了聚焦架构,我这里采用单管理员发放积分模型。
如果你已经会 OpenZeppelin,后面可以很自然地换成AccessControl。
合约设计
功能目标
- 管理员可给用户加分
- 管理员可给用户扣分
- 可查询余额
- 每次变化都发出标准化事件
Solidity 合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract PointsLedger {
address public owner;
enum ActionType {
Award,
Deduct
}
mapping(address => uint256) private _balances;
event PointsChanged(
address indexed user,
address indexed operator,
uint256 amount,
uint256 balanceAfter,
ActionType actionType,
string reason
);
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor() {
owner = msg.sender;
}
function balanceOf(address user) external view returns (uint256) {
return _balances[user];
}
function award(address user, uint256 amount, string calldata reason) external onlyOwner {
require(user != address(0), "invalid user");
require(amount > 0, "amount=0");
_balances[user] += amount;
emit PointsChanged(
user,
msg.sender,
amount,
_balances[user],
ActionType.Award,
reason
);
}
function deduct(address user, uint256 amount, string calldata reason) external onlyOwner {
require(user != address(0), "invalid user");
require(amount > 0, "amount=0");
require(_balances[user] >= amount, "insufficient points");
_balances[user] -= amount;
emit PointsChanged(
user,
msg.sender,
amount,
_balances[user],
ActionType.Deduct,
reason
);
}
function transferOwnership(address newOwner) external onlyOwner {
require(newOwner != address(0), "invalid owner");
owner = newOwner;
}
}
这个合约的几个设计点
1. 不做积分转账
很多业务积分不需要 P2P 流转。
不做转账,有两个好处:
- 权限边界更清晰
- 避免被误用成“类代币”
2. 事件里带上 balanceAfter
这不是必需,但很实用。
因为索引时可以快速校验最终状态,也方便排查数据错位。
3. reason 用字符串,而不是 bytes32
这里为了演示和可读性,使用字符串。
如果你追求 gas 优化,可以换成:
bytes32 reasonCode- 链下维护 reasonCode 字典
这在大规模积分发放场景会更省。
The Graph 建模
GraphQL Schema
type User @entity {
id: ID!
address: Bytes!
balance: BigInt!
txCount: BigInt!
createdAt: BigInt!
updatedAt: BigInt!
transactions: [PointsTransaction!]! @derivedFrom(field: "user")
}
type PointsTransaction @entity {
id: ID!
user: User!
operator: Bytes!
amount: BigInt!
balanceAfter: BigInt!
actionType: String!
reason: String!
blockNumber: BigInt!
blockTimestamp: BigInt!
transactionHash: Bytes!
logIndex: BigInt!
}
这里的思路很典型:
User表示聚合视图PointsTransaction表示流水明细transactions用@derivedFrom反向关联,不手动维护数组
subgraph.yaml
下面以本地或测试网部署为例。你需要把合约地址和起始区块换成自己的。
specVersion: 0.0.5
description: Points ledger subgraph
repository: https://example.com/points-ledger
schema:
file: ./schema.graphql
dataSources:
- kind: ethereum
name: PointsLedger
network: sepolia
source:
address: "0xYourContractAddress"
abi: PointsLedger
startBlock: 0000000
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
entities:
- User
- PointsTransaction
abis:
- name: PointsLedger
file: ./abis/PointsLedger.json
eventHandlers:
- event: PointsChanged(indexed address,indexed address,uint256,uint256,uint8,string)
handler: handlePointsChanged
file: ./src/mapping.ts
Mapping 逻辑
import { BigInt } from "@graphprotocol/graph-ts";
import { PointsChanged } from "../generated/PointsLedger/PointsLedger";
import { User, PointsTransaction } from "../generated/schema";
function getOrCreateUser(address: string, timestamp: BigInt): User {
let user = User.load(address);
if (user == null) {
user = new User(address);
user.address = Bytes.fromHexString(address) as Bytes;
user.balance = BigInt.zero();
user.txCount = BigInt.zero();
user.createdAt = timestamp;
user.updatedAt = timestamp;
}
return user as User;
}
import { Bytes } from "@graphprotocol/graph-ts";
export function handlePointsChanged(event: PointsChanged): void {
let userId = event.params.user.toHexString();
let user = getOrCreateUser(userId, event.block.timestamp);
user.balance = event.params.balanceAfter;
user.txCount = user.txCount.plus(BigInt.fromI32(1));
user.updatedAt = event.block.timestamp;
user.save();
let entityId =
event.transaction.hash.toHexString() + "-" + event.logIndex.toString();
let tx = new PointsTransaction(entityId);
tx.user = user.id;
tx.operator = event.params.operator;
tx.amount = event.params.amount;
tx.balanceAfter = event.params.balanceAfter;
tx.actionType = event.params.actionType == 0 ? "AWARD" : "DEDUCT";
tx.reason = event.params.reason;
tx.blockNumber = event.block.number;
tx.blockTimestamp = event.block.timestamp;
tx.transactionHash = event.transaction.hash;
tx.logIndex = event.logIndex;
tx.save();
}
注意:
import最好统一放文件顶部。
我这里保留完整示例,下面给出整理版。
Mapping 整理版
import { BigInt, Bytes } from "@graphprotocol/graph-ts";
import { PointsChanged } from "../generated/PointsLedger/PointsLedger";
import { User, PointsTransaction } from "../generated/schema";
function getOrCreateUser(address: string, timestamp: BigInt): User {
let user = User.load(address);
if (user == null) {
user = new User(address);
user.address = Bytes.fromHexString(address) as Bytes;
user.balance = BigInt.zero();
user.txCount = BigInt.zero();
user.createdAt = timestamp;
user.updatedAt = timestamp;
}
return user as User;
}
export function handlePointsChanged(event: PointsChanged): void {
let userId = event.params.user.toHexString();
let user = getOrCreateUser(userId, event.block.timestamp);
user.balance = event.params.balanceAfter;
user.txCount = user.txCount.plus(BigInt.fromI32(1));
user.updatedAt = event.block.timestamp;
user.save();
let entityId = event.transaction.hash.toHexString() + "-" + event.logIndex.toString();
let tx = new PointsTransaction(entityId);
tx.user = user.id;
tx.operator = event.params.operator;
tx.amount = event.params.amount;
tx.balanceAfter = event.params.balanceAfter;
tx.actionType = event.params.actionType == 0 ? "AWARD" : "DEDUCT";
tx.reason = event.params.reason;
tx.blockNumber = event.block.number;
tx.blockTimestamp = event.block.timestamp;
tx.transactionHash = event.transaction.hash;
tx.logIndex = event.logIndex;
tx.save();
}
本地开发与部署流程
1. 编译并部署合约
如果你使用 Hardhat,最小脚本可以这样写。
scripts/deploy.js
const hre = require("hardhat");
async function main() {
const PointsLedger = await hre.ethers.getContractFactory("PointsLedger");
const points = await PointsLedger.deploy();
await points.waitForDeployment();
console.log("PointsLedger deployed to:", await points.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: "0.8.20",
networks: {
hardhat: {},
},
};
安装依赖并执行:
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat compile
npx hardhat run scripts/deploy.js --network hardhat
2. 生成 ABI 并放入 subgraph
通常把编译后的 ABI 复制到:
./abis/PointsLedger.json
3. 初始化并生成代码
npm install -g @graphprotocol/graph-cli
npm install
graph codegen
graph build
4. 部署 subgraph
如果你使用 Hosted Service 或 Subgraph Studio,命令会略有区别。
核心动作是:
graph auth --studio <DEPLOY_KEY>
graph deploy --studio points-ledger
如何查询数据
部署完成后,就可以通过 GraphQL 查询。
查询单个用户积分
query GetUser($id: ID!) {
user(id: $id) {
id
address
balance
txCount
updatedAt
}
}
变量示例:
{
"id": "0x1234567890abcdef1234567890abcdef12345678"
}
查询用户积分流水
query GetUserTransactions($user: String!) {
pointsTransactions(
where: { user: $user }
orderBy: blockTimestamp
orderDirection: desc
first: 20
) {
id
amount
actionType
reason
balanceAfter
blockTimestamp
transactionHash
}
}
查询排行榜
query TopUsers {
users(orderBy: balance, orderDirection: desc, first: 20) {
id
balance
txCount
}
}
这就是 The Graph 最直接的价值:
不需要你自己扫事件,不需要你自己算排行榜。
容量估算与扩展思路
架构文章不能只停留在“能跑”。如果业务上线,数据量一定会问到。
粗略容量思路
假设:
- 每天 5 万笔积分变动
- 每年约 1825 万笔事件
- 每笔事件映射为 1 条
PointsTransaction - 活跃用户 50 万
那么实体规模大致是:
User: 50 万PointsTransaction: 1800 万+
这对“明细查询 + 排行榜”来说,索引层是必要的。
如果前端每次都从链上扫,体验基本没法接受。
扩展方向 1:任务维度
如果你希望统计“签到积分”“邀请积分”“消费积分”,可以增加实体:
CampaignTaskReasonCodeStat
但我建议第一版先不要过度建模。
先把通用流水跑通,再按分析需求演进。
扩展方向 2:多角色权限
当前只有 owner。生产环境通常会拆成:
ISSUER_ROLE:发积分DEDUCTOR_ROLE:扣积分AUDITOR_ROLE:只读审计PAUSER_ROLE:紧急暂停
这时可以改为 OpenZeppelin AccessControl。
扩展方向 3:批量发放
如果你有空投式批量积分发放需求,可以增加:
function batchAward(address[] calldata users, uint256[] calldata amounts, string calldata reason)
但要注意:
- 数组长度限制
- 单笔 gas 上限
- 失败回滚范围
通常我会建议:
批量不要贪大,宁可分批多次发。
常见坑与排查
这一部分我尽量讲得接地气一点,因为这些坑我自己确实踩过。
坑 1:事件签名写错,The Graph 根本不触发
现象
subgraph 部署成功,但数据一直为空。
排查
重点检查 subgraph.yaml 中的事件签名是否和 ABI 完全一致:
event: PointsChanged(indexed address,indexed address,uint256,uint256,uint8,string)
如果枚举在 ABI 里表现为 uint8,你就必须写 uint8,不能写 ActionType。
建议
最稳的方法是:
- 从 ABI 生成结果核对
- 不要手敲复杂事件签名
坑 2:实体 ID 冲突
错误做法
只用 transaction.hash 作为流水 ID。
为什么错
一笔交易里可能触发多个同名事件。
如果只用交易哈希,会覆盖前一条记录。
正确做法
用:
event.transaction.hash.toHexString() + "-" + event.logIndex.toString()
这样基本就稳了。
坑 3:链上余额和索引余额不一致
可能原因
- subgraph 从错误的
startBlock开始 - 重新部署合约但 ABI/地址没同步
- mapping 里错误地自己计算余额,而不是用
balanceAfter - 遇到链重组后未正确处理
我的经验
如果事件已经带 balanceAfter,索引层就不要重复做加减运算。
直接信任链上事件输出的最终余额,能少掉很多状态偏差。
坑 4:reason 文本过长,gas 成本明显上涨
现象
发积分功能能用,但 gas 比预期高很多。
原因
字符串是动态数据,链上存取和日志写入都会更贵。
解决思路
把:
string reason
改成:
bytes32 reasonCode
然后链下维护:
SIGN_ININVITE_SUCCESSORDER_REWARD
如果运营确实需要长文本说明,建议放链下系统,链上只放代码或摘要。
坑 5:GraphQL 查排行榜很慢
可能原因
- 数据量大,排序字段使用频繁
- 查询条件设计不合理
- 想让 The Graph 做过重聚合
解决思路
- 常用排行榜字段尽量直接存到
User - 不要每次临时从流水聚合余额
- 超复杂 BI 需求转离线分析系统
边界要清楚:
The Graph 是索引查询层,不是全能 OLAP 引擎。
安全最佳实践
积分系统虽然不像资产合约那样高价值,但一旦被滥发,业务损失往往很直接。
1. 权限控制要比你想象得严格
如果只有一个 owner,至少做到:
- owner 使用多签地址
- 发放脚本权限最小化
- 管理后台记录谁发起、为什么发起
如果业务规模再大一点,建议切到角色控制。
2. 扣分逻辑必须显式校验
像下面这种检查不能少:
require(_balances[user] >= amount, "insufficient points");
虽然 Solidity 0.8+ 有溢出检查,但业务上的余额不足仍然要手动判断。
3. 不要把链下“审核通过”假设成链上已可信
常见模式是:
- 后端判断用户完成任务
- 后端代管理员发交易 award
这本身没有问题,但要接受一个事实:
链上只是记账可信,不代表任务判断天然可信。
如果任务条件不是链上原生事件,审计重点就要放在后台发放逻辑。
4. 关键操作发事件,不要偷懒
哪怕已经更新了状态,也一定要发事件。
事件不仅给 The Graph 用,也是后续审计、追责、对账的基础。
性能最佳实践
1. 合约状态最小化
不要把所有业务维度都塞进链上存储。
链上只放:
- 当前余额
- 必要权限
- 必要事件
复杂统计、标签、分类分析交给索引层。
2. 事件字段“够用即可”
事件并不是越全越好。
字段多了,gas 也会增加。
我一般按这个优先级选字段:
- 查询必须用到的
- 审计必须用到的
- 排查问题有明显价值的
超出这个范围的,尽量放链下。
3. 用聚合实体服务高频查询
比如排行榜常查 balance,就直接存到 User.balance。
不要每次从所有 PointsTransaction 累加。
这是典型的写时聚合,读时加速。
4. 批量发放要控制单笔规模
虽然批量接口能省操作次数,但如果一次打太多地址:
- 容易超过 gas limit
- 失败时整体回滚
- 排查成本高
更稳妥的做法是:
- 每批 50~200 个地址,视链上 gas 情况调整
- 记录批次 ID
- 支持失败重试
一个更稳的生产化演进路线
如果你准备把这个系统真正用于业务,我建议按下面三阶段推进:
stateDiagram-v2
[*] --> MVP
MVP --> Growth
Growth --> Production
state MVP {
[*] --> 单管理员
单管理员 --> 单事件索引
}
state Growth {
[*] --> 多角色权限
多角色权限 --> 批量发放
批量发放 --> 任务分类统计
}
state Production {
[*] --> 多签治理
多签治理 --> 风控审计
风控审计 --> 数据对账
}
MVP 阶段
先验证三件事:
- 发分/扣分流程能跑通
- GraphQL 能查余额和流水
- 前端能稳定展示
Growth 阶段
增加:
- 多角色
- 批量接口
- reasonCode
- 排行榜页和运营报表
Production 阶段
补足:
- 多签
- 审计日志
- 监控告警
- 链上余额与索引层定时对账
一个实用的前端查询示例
如果你用 JavaScript 调 GraphQL,可以这样写。
const endpoint = "https://api.studio.thegraph.com/query/your-subgraph/version/latest";
async function fetchTopUsers() {
const query = `
query {
users(orderBy: balance, orderDirection: desc, first: 10) {
id
balance
txCount
}
}
`;
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ query })
});
const data = await res.json();
return data.data.users;
}
fetchTopUsers().then(console.log).catch(console.error);
这就是最终体验上的差异:
前端不用研究怎么扫区块、怎么解日志、怎么分页,直接按业务对象查询。
总结
如果你要做一个中级可落地的链上积分系统,我建议记住一句话:
Solidity 负责可信记账,The Graph 负责可查询视图。
这套组合的价值在于:
- 链上积分余额可验证
- 每笔积分变化可审计
- 前端和后台能高效查询明细与排行榜
- 架构清晰,后续扩展空间大
最后给几个可执行建议
-
第一版先做最小模型
balanceOfPointsChangedUserPointsTransaction
-
事件设计一次想清楚 尤其是
operator、actionType、reason/reasonCode、balanceAfter。 -
不要让 The Graph 替你做所有分析 它适合查询层,不适合无限复杂的 BI 聚合。
-
生产环境优先考虑权限与审计 积分系统最大的风险通常不是代码崩,而是“谁在滥发分”。
-
当数据量上来时,尽早建立对账机制 定期抽样校验链上
balanceOf与索引层User.balance是否一致。
如果你已经做过 ERC-20 或 NFT 项目,那么这套链上积分系统其实是一个很好的进阶练习:
它不像资产协议那样复杂,但足够让你真正理解 “链上状态 + 索引层查询” 的 Web3 典型架构。