跳转到内容
123xiao | 无名键客

《Web3 中级实战:基于 Solidity 与 The Graph 构建可查询的链上积分系统》

字数: 0 阅读时长: 1 分钟

Web3 中级实战:基于 Solidity 与 The Graph 构建可查询的链上积分系统

很多团队第一次做“链上积分”时,直觉是:写一个 Solidity 合约,记录用户积分增减,然后前端直接查合约。
但真到业务跑起来,很快就会发现几个现实问题:

  • 链上原始数据不适合复杂查询
  • 排行榜、明细列表、按时间过滤 这些需求,用 RPC 直接扫事件会越来越慢
  • 前端自己聚合数据,开发成本高,重放逻辑还容易出错
  • 积分规则一旦变复杂,比如签到、邀请、任务奖励、管理员纠偏,数据模型会迅速失控

这篇文章我想从“架构落地”的角度,带你做一个可查询的链上积分系统
Solidity 负责可信记账,用 The Graph 负责可查询索引。这样既保留链上可验证性,又能让业务方像查数据库一样查积分明细和排行榜。


背景与问题

为什么“只写合约”不够

积分系统和代币系统不完全一样。很多业务场景并不需要可转账资产,而是需要:

  • 给某用户增加积分
  • 扣减积分
  • 记录每一笔积分变动的原因
  • 查询某地址当前总积分
  • 查询某用户最近 20 条积分流水
  • 做排行榜
  • 按任务类型或时间段分析积分发放情况

如果只依赖链上状态变量,通常会遇到两个问题:

  1. 状态好查,历史难查
    比如 balances[address] 能读到当前积分,但“这 100 分怎么来的”需要扫事件。

  2. 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


核心原理

积分系统最小可用模型

一个可维护的链上积分系统,我建议至少有这几层数据:

  1. 账户总积分
  2. 每次积分变动流水
  3. 变动原因或业务类型
  4. 操作者信息

在链上,我们只保留最关键的可信状态:

  • balanceOf[user]
  • 权限控制
  • 事件日志

在索引层,构建可查询实体:

  • User
  • PointsTransaction

事件驱动的数据流

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.graphql
    • subgraph.yaml
    • src/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:任务维度

如果你希望统计“签到积分”“邀请积分”“消费积分”,可以增加实体:

  • Campaign
  • Task
  • ReasonCodeStat

但我建议第一版先不要过度建模。
先把通用流水跑通,再按分析需求演进。


扩展方向 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_IN
  • INVITE_SUCCESS
  • ORDER_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 也会增加。
我一般按这个优先级选字段:

  1. 查询必须用到的
  2. 审计必须用到的
  3. 排查问题有明显价值的

超出这个范围的,尽量放链下。


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 负责可查询视图。

这套组合的价值在于:

  • 链上积分余额可验证
  • 每笔积分变化可审计
  • 前端和后台能高效查询明细与排行榜
  • 架构清晰,后续扩展空间大

最后给几个可执行建议

  1. 第一版先做最小模型

    • balanceOf
    • PointsChanged
    • User
    • PointsTransaction
  2. 事件设计一次想清楚 尤其是 operatoractionTypereason/reasonCodebalanceAfter

  3. 不要让 The Graph 替你做所有分析 它适合查询层,不适合无限复杂的 BI 聚合。

  4. 生产环境优先考虑权限与审计 积分系统最大的风险通常不是代码崩,而是“谁在滥发分”。

  5. 当数据量上来时,尽早建立对账机制 定期抽样校验链上 balanceOf 与索引层 User.balance 是否一致。

如果你已经做过 ERC-20 或 NFT 项目,那么这套链上积分系统其实是一个很好的进阶练习:
它不像资产协议那样复杂,但足够让你真正理解 “链上状态 + 索引层查询” 的 Web3 典型架构。


分享到:

上一篇
《Web逆向实战:中级开发者如何定位并复现前端签名参数生成逻辑》
下一篇
《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固》