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

《Web3 中级实战:基于智能合约与钱包登录构建链上会员积分系统》

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

Web3 中级实战:基于智能合约与钱包登录构建链上会员积分系统

很多团队做 Web3 会员体系时,第一反应是“把积分写到链上就完了”。真到落地阶段,问题会一个接一个冒出来:

  • 钱包登录怎么和业务用户绑定?
  • 积分变更要不要全上链?
  • 前端如何证明“这个地址真是本人”?
  • 合约怎样防止任意人乱加积分?
  • 查询榜单、会员等级、签到历史时,链上读取是不是太慢太贵?

这篇文章我不打算只讲概念,而是从架构设计 + 智能合约 + 钱包登录 + 后端签名发放的角度,带你搭一套可运行的链上会员积分系统。目标读者是已经接触过 Solidity、EVM 钱包登录、Node.js 的中级开发者。

这套方案的核心思路是:

  1. 钱包签名完成身份认证
  2. 后端校验业务行为并生成授权签名
  3. 用户或服务端调用积分合约完成铸记/记分
  4. 链上记录关键状态,链下做聚合查询和排行榜

如果你之前只做过 NFT Mint 或 ERC20 转账,这篇文章会帮你把这些零散能力拼成一个完整业务系统。


背景与问题

传统会员积分系统里,积分都在中心化数据库中。它的优点很明显:

  • 查询快
  • 成本低
  • 业务灵活
  • 排名和统计方便

但它也有几个天然短板:

  • 用户不真正拥有积分资产
  • 平台改规则、清零、封号,透明度不足
  • 跨应用或跨品牌联动困难
  • 难以和链上身份、NFT、任务系统自然打通

而如果直接把每一次积分变动都写上链,也会遇到现实问题:

  • Gas 成本高
  • 高并发活动容易拥堵
  • 排行榜等复杂查询不适合链上做
  • 权限管理、签名验证、重放攻击防护都要设计好

所以一个成熟的 Web3 会员积分系统,往往不是“全链上”或“全链下”的二选一,而是链上可信记账 + 链下业务编排的组合架构。

适用场景

这类系统特别适合:

  • 社区活跃任务积分
  • 会员等级成长值
  • NFT 持有者权益累计
  • 签到、邀请、消费返积分
  • 多 DApp 共用的身份与激励体系

设计目标

我们先明确系统目标:

  • 用户使用钱包登录,无需密码
  • 积分由合约管理,关键记账可审计
  • 只有被授权的积分发放动作才能成功
  • 支持签到、任务完成、消费返积分等多种来源
  • 支持链下聚合统计,避免纯链上查询性能问题
  • 架构可扩展到多活动、多等级、多链部署

方案对比与取舍分析

在开始写代码前,先看三种常见方案。

方案描述优点缺点适用情况
全链下积分数据库存积分,钱包仅做登录成本低、快透明性弱、不可组合早期 MVP
全链上积分每次加减分都写链强透明、可组合成本高、查询弱高频不高、强调公开审计
混合式积分链上存关键状态,链下存统计索引平衡透明性与性能架构稍复杂大多数业务系统

这篇文章选择的是混合式架构,原因很现实:

  • 积分账本可信:关键积分变更由合约记录
  • 认证链路清晰:钱包签名登录,后端发授权签名
  • 查询体验可接受:排行榜、任务明细通过索引服务完成
  • 业务扩展更自然:以后接 NFT 徽章、等级权益、兑换商城都方便

核心原理

整个系统可以拆成四个模块:

  1. 前端 DApp

    • 连接钱包
    • 发起登录签名
    • 展示积分、等级、任务状态
    • 调用合约执行领取积分
  2. 认证与业务后端

    • 生成登录 nonce
    • 验证钱包签名
    • 校验任务是否完成
    • 生成“积分授权签名”
  3. 积分合约

    • 校验签名是否来自授权 signer
    • 防止重放攻击
    • 更新用户积分
    • 发出事件供链下索引
  4. 索引与查询层

    • 监听链上事件
    • 写入 PostgreSQL / Elastic / Redis
    • 提供排行榜、等级页、任务记录查询 API

架构总览

flowchart LR
    U[用户钱包] --> F[前端 DApp]
    F --> A[认证后端]
    A --> DB[(业务数据库)]
    A --> S[授权签名服务]
    F --> C[积分智能合约]
    C --> E[链上事件 Logs]
    E --> I[索引器/监听服务]
    I --> Q[(查询库)]
    F --> API[查询 API]
    API --> Q

这个图里最关键的点是:真正能改积分的权力,不在前端,而在“后端校验 + 合约验签”这条链路上。


关键时序:钱包登录 + 积分领取

sequenceDiagram
    participant User as 用户
    participant FE as 前端
    participant BE as 后端
    participant Wallet as 钱包
    participant Contract as 积分合约

    User->>FE: 点击连接钱包
    FE->>Wallet: 请求签名登录消息
    Wallet-->>FE: 返回签名
    FE->>BE: address + signature + nonce
    BE->>BE: 验证签名并创建会话

    User->>FE: 点击领取签到积分
    FE->>BE: 请求积分授权
    BE->>BE: 校验是否满足任务条件
    BE-->>FE: 返回 claim 签名

    FE->>Contract: claim(points, actionId, deadline, signature)
    Contract->>Contract: 校验 signer / nonce / deadline
    Contract-->>FE: 积分到账

数据模型设计

建议把“业务动作”和“积分结果”分开理解。

链上核心字段

  • user => points:用户累计积分
  • usedActionIds:动作唯一 ID,防止重复领取
  • authorizedSigner:授权签名地址
  • events:积分发放事件,用于链下索引

链下核心字段

  • 用户资料表:地址、昵称、邀请人、注册时间
  • 任务表:签到、分享、消费、绑定社媒等
  • 积分流水表:动作来源、分值、时间、链上 tx hash
  • 等级配置表:Bronze / Silver / Gold 门槛

actionId 的设计建议

我比较推荐后端生成一个全局唯一的 actionId,比如:

  • keccak256(user + actionType + bizId + date)
  • 或业务数据库主键映射后再 hash

这样可以很好解决这些问题:

  • 同一天签到不能重复领
  • 同一笔订单返积分只能记一次
  • 同一个任务成就只能达成一次

智能合约设计

这里我们实现一个中等复杂度、可直接运行的积分合约:

  • 使用 ECDSA 验签
  • 由后端 signer 授权具体发分行为
  • 用户自行调用 claim
  • 防重放、防过期
  • 支持管理员调整 signer

合约代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract MembershipPoints is Ownable {
    using ECDSA for bytes32;

    mapping(address => uint256) public points;
    mapping(bytes32 => bool) public usedActionIds;

    address public authorizedSigner;

    event PointsClaimed(
        address indexed user,
        uint256 amount,
        bytes32 indexed actionId,
        uint256 newTotal
    );

    event SignerUpdated(address indexed oldSigner, address indexed newSigner);

    constructor(address initialOwner, address initialSigner) Ownable(initialOwner) {
        require(initialSigner != address(0), "invalid signer");
        authorizedSigner = initialSigner;
    }

    function setAuthorizedSigner(address newSigner) external onlyOwner {
        require(newSigner != address(0), "invalid signer");
        address old = authorizedSigner;
        authorizedSigner = newSigner;
        emit SignerUpdated(old, newSigner);
    }

    function claim(
        uint256 amount,
        bytes32 actionId,
        uint256 deadline,
        bytes calldata signature
    ) external {
        require(block.timestamp <= deadline, "signature expired");
        require(!usedActionIds[actionId], "action already used");
        require(amount > 0, "invalid amount");

        bytes32 messageHash = keccak256(
            abi.encodePacked(msg.sender, amount, actionId, deadline, address(this), block.chainid)
        );

        bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
        address recovered = ethSignedMessageHash.recover(signature);

        require(recovered == authorizedSigner, "invalid signer");

        usedActionIds[actionId] = true;
        points[msg.sender] += amount;

        emit PointsClaimed(msg.sender, amount, actionId, points[msg.sender]);
    }

    function batchGrantByOwner(
        address[] calldata users,
        uint256[] calldata amounts
    ) external onlyOwner {
        require(users.length == amounts.length, "length mismatch");

        for (uint256 i = 0; i < users.length; i++) {
            require(users[i] != address(0), "invalid user");
            require(amounts[i] > 0, "invalid amount");
            points[users[i]] += amounts[i];
            emit PointsClaimed(users[i], amounts[i], keccak256(abi.encodePacked("owner", i, block.number)), points[users[i]]);
        }
    }

    function getPoints(address user) external view returns (uint256) {
        return points[user];
    }
}

合约状态与权限关系

stateDiagram-v2
    [*] --> 未登录
    未登录 --> 已登录: 钱包签名成功
    已登录 --> 可请求授权: 完成任务/满足业务条件
    可请求授权 --> 可领取: 后端生成 claim 签名
    可领取 --> 已记分: 合约验签通过并写链
    可领取 --> 已失效: deadline 过期
    可领取 --> 已失效: actionId 已被使用
    已记分 --> 可请求授权: 新任务产生

实战代码(可运行)

下面我们做一套最小可运行示例,技术栈如下:

  • 合约:Solidity + OpenZeppelin
  • 开发环境:Hardhat
  • 后端:Node.js + Express + ethers
  • 前端交互:ethers v6

环境准备

mkdir web3-membership-points
cd web3-membership-points
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts express cors dotenv ethers
npx hardhat

选择一个基础 JavaScript 项目结构。

hardhat.config.js

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
  solidity: "0.8.24",
  networks: {
    hardhat: {},
    sepolia: {
      url: process.env.RPC_URL,
      accounts: [process.env.PRIVATE_KEY]
    }
  }
};

目录建议

contracts/
  MembershipPoints.sol
scripts/
  deploy.js
backend/
  server.js
frontend/
  demo.js

部署脚本

scripts/deploy.js

const { ethers } = require("hardhat");

async function main() {
  const [deployer, signer] = await ethers.getSigners();

  console.log("Deployer:", deployer.address);
  console.log("Authorized Signer:", signer.address);

  const Contract = await ethers.getContractFactory("MembershipPoints");
  const contract = await Contract.deploy(deployer.address, signer.address);
  await contract.waitForDeployment();

  console.log("MembershipPoints deployed to:", await contract.getAddress());
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

运行:

npx hardhat compile
npx hardhat run scripts/deploy.js

后端:登录验签与积分授权

为了简单清晰,这里把“登录”和“积分授权”都放在一个 Express 服务里演示。

backend/server.js

require("dotenv").config();
const express = require("express");
const cors = require("cors");
const { ethers } = require("ethers");

const app = express();
app.use(cors());
app.use(express.json());

const PORT = process.env.PORT || 3001;

// 演示环境:实际生产应放 Redis / DB
const nonces = new Map();
const sessions = new Map();
const claimedCheckinDays = new Set();

const signerWallet = new ethers.Wallet(process.env.SIGNER_PRIVATE_KEY);

// 1. 获取登录 nonce
app.get("/auth/nonce", (req, res) => {
  const address = req.query.address;
  if (!address || !ethers.isAddress(address)) {
    return res.status(400).json({ error: "invalid address" });
  }

  const nonce = `Login to Membership System: ${Date.now()}:${Math.random()}`;
  nonces.set(address.toLowerCase(), nonce);

  res.json({ nonce });
});

// 2. 验证钱包登录签名
app.post("/auth/verify", async (req, res) => {
  try {
    const { address, signature } = req.body;
    const lower = address?.toLowerCase();

    if (!lower || !signature || !ethers.isAddress(address)) {
      return res.status(400).json({ error: "invalid params" });
    }

    const nonce = nonces.get(lower);
    if (!nonce) {
      return res.status(400).json({ error: "nonce not found" });
    }

    const recovered = ethers.verifyMessage(nonce, signature);
    if (recovered.toLowerCase() !== lower) {
      return res.status(401).json({ error: "signature invalid" });
    }

    const token = ethers.hexlify(ethers.randomBytes(16));
    sessions.set(token, { address: lower, loginAt: Date.now() });
    nonces.delete(lower);

    res.json({ token, address: lower });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 简单鉴权中间件
function auth(req, res, next) {
  const token = req.headers.authorization?.replace("Bearer ", "");
  const session = sessions.get(token);
  if (!session) {
    return res.status(401).json({ error: "unauthorized" });
  }
  req.user = session;
  next();
}

// 3. 请求签到积分授权签名
app.post("/points/checkin-sign", auth, async (req, res) => {
  try {
    const user = req.user.address;
    const today = new Date().toISOString().slice(0, 10);
    const bizKey = `${user}:${today}`;

    if (claimedCheckinDays.has(bizKey)) {
      return res.status(400).json({ error: "already checked in today" });
    }

    const amount = 10;
    const actionId = ethers.keccak256(
      ethers.solidityPacked(
        ["address", "string", "string"],
        [user, "daily-checkin", today]
      )
    );

    const deadline = Math.floor(Date.now() / 1000) + 10 * 60;
    const contractAddress = process.env.CONTRACT_ADDRESS;
    const chainId = Number(process.env.CHAIN_ID || 31337);

    const messageHash = ethers.keccak256(
      ethers.solidityPacked(
        ["address", "uint256", "bytes32", "uint256", "address", "uint256"],
        [user, amount, actionId, deadline, contractAddress, chainId]
      )
    );

    const signature = await signerWallet.signMessage(ethers.getBytes(messageHash));

    // 注意:演示里先占位,生产中最好在“链上成功后”再最终确认
    claimedCheckinDays.add(bizKey);

    res.json({
      amount,
      actionId,
      deadline,
      signature
    });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.listen(PORT, () => {
  console.log(`Backend running at http://localhost:${PORT}`);
});

.env 示例

PORT=3001
SIGNER_PRIVATE_KEY=0xyour_signer_private_key
CONTRACT_ADDRESS=0xyour_deployed_contract
CHAIN_ID=31337
RPC_URL=http://127.0.0.1:8545
PRIVATE_KEY=0xyour_deployer_private_key

前端交互示例

这里给一个最小化浏览器脚本思路,你也可以很容易迁移到 React / Next.js。

frontend/demo.js

import { ethers } from "ethers";

const CONTRACT_ADDRESS = "0xyour_deployed_contract";

const ABI = [
  "function claim(uint256 amount, bytes32 actionId, uint256 deadline, bytes signature) external",
  "function getPoints(address user) external view returns (uint256)"
];

let provider;
let signer;
let userAddress;
let authToken;

export async function connectWallet() {
  provider = new ethers.BrowserProvider(window.ethereum);
  signer = await provider.getSigner();
  userAddress = await signer.getAddress();
  console.log("Connected:", userAddress);
}

export async function login() {
  const nonceResp = await fetch(`http://localhost:3001/auth/nonce?address=${userAddress}`);
  const { nonce } = await nonceResp.json();

  const signature = await signer.signMessage(nonce);

  const verifyResp = await fetch("http://localhost:3001/auth/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      address: userAddress,
      signature
    })
  });

  const data = await verifyResp.json();
  authToken = data.token;
  console.log("Login success:", data);
}

export async function claimCheckin() {
  const signResp = await fetch("http://localhost:3001/points/checkin-sign", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${authToken}`
    }
  });

  const { amount, actionId, deadline, signature } = await signResp.json();

  const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, signer);
  const tx = await contract.claim(amount, actionId, deadline, signature);
  await tx.wait();

  console.log("Claim success:", tx.hash);
}

export async function queryPoints() {
  const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, provider);
  const result = await contract.getPoints(userAddress);
  console.log("Points:", result.toString());
}

本地联调步骤

1. 启动本地区块链

npx hardhat node

2. 部署合约

npx hardhat run scripts/deploy.js --network localhost

3. 启动后端

node backend/server.js

4. 浏览器中执行流程

顺序是:

  1. connectWallet()
  2. login()
  3. claimCheckin()
  4. queryPoints()

如果一切正常,你会看到:

  • 登录成功返回 token
  • 签到领取成功返回 tx hash
  • 查询积分返回 10

链下索引设计

纯链上适合“记账”,不适合“做报表”。所以建议你监听 PointsClaimed 事件写入查询库。

事件监听示例

const { ethers } = require("ethers");

const provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545");

const abi = [
  "event PointsClaimed(address indexed user, uint256 amount, bytes32 indexed actionId, uint256 newTotal)"
];

const contract = new ethers.Contract("0xyour_deployed_contract", abi, provider);

contract.on("PointsClaimed", async (user, amount, actionId, newTotal, event) => {
  console.log("PointsClaimed:", {
    user,
    amount: amount.toString(),
    actionId,
    newTotal: newTotal.toString(),
    txHash: event.log.transactionHash,
    blockNumber: event.log.blockNumber
  });

  // TODO: 写入数据库
});

为什么索引层很重要

因为下面这些需求,几乎都更适合链下做:

  • 排行榜 Top N
  • 某用户近 30 天积分变化趋势
  • 不同活动来源占比分析
  • 等级快照与批量发券
  • 任务漏斗分析

容量估算与架构扩展

这是 architecture 类型文章里很容易被忽略的一块,但真上线时非常关键。

写入压力估算

假设:

  • 日活 5 万用户
  • 每用户平均 2 次积分动作
  • 每日 10 万次积分领取

如果全部主网写入,Gas 成本会很不友好;如果在 L2 或高性能 EVM 链上,成本会合理很多。

推荐分层策略

低频高价值行为:上链

比如:

  • 消费返积分
  • 成就解锁
  • 权益兑换扣积分
  • 会员等级升级快照

高频低价值行为:可聚合上链

比如:

  • 浏览、点赞、日常微任务
  • 可以先链下累计,达到阈值后再合并结算上链

扩展方向

  1. 从单积分升级到多积分池

    • 成长值
    • 可消费积分
    • 活动积分
  2. 加入等级系统

    • 合约只存积分
    • 等级规则链下计算,定期上链快照
    • 或直接链上存等级阈值
  3. 加入兑换商城

    • 扣积分需要额外设计 spend() 流程
    • 建议引入更严格权限和订单状态校验

常见坑与排查

这部分我尽量写得接地气一点,因为这些坑我自己就踩过不少。

1. 后端签名和合约验签不一致

现象:

  • 前端调用 claim 一直报 invalid signer

常见原因:

  • abi.encodePacked 与后端 solidityPacked 字段顺序不一致
  • 漏了 address(this)chainId
  • 后端签的是原始文本,合约验的是哈希后的 toEthSignedMessageHash

排查建议:

  • 把合约 message 字段逐项打印出来
  • 后端本地用同样参数恢复 signer 地址
  • 固定一组测试输入做快照测试

2. actionId 被错误复用

现象:

  • 不同业务动作出现 action already used

原因:

  • actionId 生成规则不唯一
  • 只用了日期,没拼用户地址或业务 ID

建议:

  • actionId 至少包含:user + actionType + bizId/time bucket
  • 不同业务类型应有显式命名空间

3. 登录签名被重放

现象:

  • 用户只签过一次,却被多次伪造登录

原因:

  • nonce 没有及时失效
  • 服务端未绑定会话过期时间

建议:

  • nonce 一次性使用
  • 登录成功后立即删除
  • 会话 token 设置 TTL
  • 最好使用 SIWE(Sign-In With Ethereum)标准化消息格式

4. 后端“先记成功”但链上交易失败

现象:

  • 数据库显示已领取,链上却没到账

原因:

  • 先写业务状态,后发链上交易或用户没真正提交成功

建议:

  • 如果是“用户自行 claim”模式,后端只能标记为“已签发待上链”
  • 最终成功状态要以链上事件为准
  • 建议建立状态机:created -> signed -> submitted -> confirmed -> indexed

5. 事件监听漏块或重复消费

现象:

  • 查询库里有缺失数据或重复积分流水

建议:

  • 保存最后处理区块号
  • 支持区块回滚重扫
  • 数据库层用 txHash + logIndex 做幂等唯一键

安全/性能最佳实践

这部分是整套系统最值钱的地方。很多 Demo 能跑,但一到生产就暴露问题。

安全最佳实践

1. 把签名域绑定到具体合约和链

我们在消息里加入了:

  • address(this)
  • block.chainid

这样可以避免:

  • 同一个签名在其他链复用
  • 同一个签名在其他合约复用

2. 所有授权都必须有过期时间

没有 deadline 的签名,非常容易变成长期有效凭证。一旦泄露,风险很大。

3. 使用一次性 actionId

这是防重放最核心的手段。不要只靠时间戳。

4. signer 私钥要独立保管

不要用部署私钥兼任业务 signer。最好:

  • 分开账户
  • 放 KMS/HSM 或托管签名系统
  • 定期轮换,并支持链上更新 signer

5. 管理员批量发分要谨慎

batchGrantByOwner 只是管理兜底手段,不应成为日常主流程。否则系统会重新滑回中心化。

6. 尽量避免链上复杂字符串处理

字符串 Gas 更高,哈希后再传 bytes32 往往更省。


性能最佳实践

1. 读多写少分层处理

  • 写:链上
  • 查:索引层 + 缓存层

这是最实用的性能策略。

2. 高频任务采用批处理

比如 100 次轻量互动,不必每次都上链,可以链下累计后按日结算。

3. 前端缓存静态元数据

会员等级规则、任务配置、活动说明等没必要每次都链上读。

4. 排行榜不要直接扫链

即使数据量不大,也不建议前端实时全量读取事件算排名。正确做法是:

  • 后端预聚合
  • Redis 缓存热榜
  • 定时校准链上真值

进阶演进思路

如果你准备把这套系统用于正式业务,建议按下面路线逐步升级。

第一阶段:最小可用版本

  • 钱包登录
  • 每日签到积分
  • 合约记账
  • 查询当前积分

第二阶段:业务化

  • 消费返积分
  • 邀请奖励
  • 等级系统
  • 积分流水页
  • 运营后台

第三阶段:生态化

  • NFT 徽章联动
  • 跨项目共享会员身份
  • 积分兑换权益
  • 多链部署与桥接映射

第四阶段:标准化

  • SIWE 登录标准
  • EIP-712 结构化签名
  • The Graph / 自建索引平台
  • 审计与风控策略

如果是正式生产,我会优先把本文里的 signMessage 升级成 EIP-712 Typed Data。它更清晰、更安全,钱包展示也更友好。


总结

基于智能合约与钱包登录构建链上会员积分系统,真正的关键不只是“把积分上链”,而是把下面三件事打通:

  1. 身份可信:用户通过钱包签名完成认证
  2. 记账可信:只有授权签名对应的积分变更才能写链
  3. 查询可用:复杂统计和排行榜通过链下索引完成

如果你要快速落地,我建议按这个边界来做:

  • 必须上链:积分最终记账、关键奖励发放、兑换扣减
  • 适合链下:排行榜、任务判定细节、活动运营分析
  • 必须重点防护:签名重放、actionId 冲突、会话伪造、事件幂等

一句话概括这套架构的取舍:

把“可信性”放到链上,把“灵活性”和“查询效率”留给链下。

这通常是 Web3 会员积分系统里,最稳、也最容易扩展的一条路径。


分享到:

上一篇
欢迎使用 AstroPaper
下一篇
《自动化测试稳定性治理实战:从脆弱用例定位到持续集成中的误报率下降》