Web3 中级实战:基于智能合约与钱包登录构建链上会员积分系统
很多团队做 Web3 会员体系时,第一反应是“把积分写到链上就完了”。真到落地阶段,问题会一个接一个冒出来:
- 钱包登录怎么和业务用户绑定?
- 积分变更要不要全上链?
- 前端如何证明“这个地址真是本人”?
- 合约怎样防止任意人乱加积分?
- 查询榜单、会员等级、签到历史时,链上读取是不是太慢太贵?
这篇文章我不打算只讲概念,而是从架构设计 + 智能合约 + 钱包登录 + 后端签名发放的角度,带你搭一套可运行的链上会员积分系统。目标读者是已经接触过 Solidity、EVM 钱包登录、Node.js 的中级开发者。
这套方案的核心思路是:
- 钱包签名完成身份认证
- 后端校验业务行为并生成授权签名
- 用户或服务端调用积分合约完成铸记/记分
- 链上记录关键状态,链下做聚合查询和排行榜
如果你之前只做过 NFT Mint 或 ERC20 转账,这篇文章会帮你把这些零散能力拼成一个完整业务系统。
背景与问题
传统会员积分系统里,积分都在中心化数据库中。它的优点很明显:
- 查询快
- 成本低
- 业务灵活
- 排名和统计方便
但它也有几个天然短板:
- 用户不真正拥有积分资产
- 平台改规则、清零、封号,透明度不足
- 跨应用或跨品牌联动困难
- 难以和链上身份、NFT、任务系统自然打通
而如果直接把每一次积分变动都写上链,也会遇到现实问题:
- Gas 成本高
- 高并发活动容易拥堵
- 排行榜等复杂查询不适合链上做
- 权限管理、签名验证、重放攻击防护都要设计好
所以一个成熟的 Web3 会员积分系统,往往不是“全链上”或“全链下”的二选一,而是链上可信记账 + 链下业务编排的组合架构。
适用场景
这类系统特别适合:
- 社区活跃任务积分
- 会员等级成长值
- NFT 持有者权益累计
- 签到、邀请、消费返积分
- 多 DApp 共用的身份与激励体系
设计目标
我们先明确系统目标:
- 用户使用钱包登录,无需密码
- 积分由合约管理,关键记账可审计
- 只有被授权的积分发放动作才能成功
- 支持签到、任务完成、消费返积分等多种来源
- 支持链下聚合统计,避免纯链上查询性能问题
- 架构可扩展到多活动、多等级、多链部署
方案对比与取舍分析
在开始写代码前,先看三种常见方案。
| 方案 | 描述 | 优点 | 缺点 | 适用情况 |
|---|---|---|---|---|
| 全链下积分 | 数据库存积分,钱包仅做登录 | 成本低、快 | 透明性弱、不可组合 | 早期 MVP |
| 全链上积分 | 每次加减分都写链 | 强透明、可组合 | 成本高、查询弱 | 高频不高、强调公开审计 |
| 混合式积分 | 链上存关键状态,链下存统计索引 | 平衡透明性与性能 | 架构稍复杂 | 大多数业务系统 |
这篇文章选择的是混合式架构,原因很现实:
- 积分账本可信:关键积分变更由合约记录
- 认证链路清晰:钱包签名登录,后端发授权签名
- 查询体验可接受:排行榜、任务明细通过索引服务完成
- 业务扩展更自然:以后接 NFT 徽章、等级权益、兑换商城都方便
核心原理
整个系统可以拆成四个模块:
-
前端 DApp
- 连接钱包
- 发起登录签名
- 展示积分、等级、任务状态
- 调用合约执行领取积分
-
认证与业务后端
- 生成登录 nonce
- 验证钱包签名
- 校验任务是否完成
- 生成“积分授权签名”
-
积分合约
- 校验签名是否来自授权 signer
- 防止重放攻击
- 更新用户积分
- 发出事件供链下索引
-
索引与查询层
- 监听链上事件
- 写入 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. 浏览器中执行流程
顺序是:
connectWallet()login()claimCheckin()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 链上,成本会合理很多。
推荐分层策略
低频高价值行为:上链
比如:
- 消费返积分
- 成就解锁
- 权益兑换扣积分
- 会员等级升级快照
高频低价值行为:可聚合上链
比如:
- 浏览、点赞、日常微任务
- 可以先链下累计,达到阈值后再合并结算上链
扩展方向
-
从单积分升级到多积分池
- 成长值
- 可消费积分
- 活动积分
-
加入等级系统
- 合约只存积分
- 等级规则链下计算,定期上链快照
- 或直接链上存等级阈值
-
加入兑换商城
- 扣积分需要额外设计
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。它更清晰、更安全,钱包展示也更友好。
总结
基于智能合约与钱包登录构建链上会员积分系统,真正的关键不只是“把积分上链”,而是把下面三件事打通:
- 身份可信:用户通过钱包签名完成认证
- 记账可信:只有授权签名对应的积分变更才能写链
- 查询可用:复杂统计和排行榜通过链下索引完成
如果你要快速落地,我建议按这个边界来做:
- 必须上链:积分最终记账、关键奖励发放、兑换扣减
- 适合链下:排行榜、任务判定细节、活动运营分析
- 必须重点防护:签名重放、actionId 冲突、会话伪造、事件幂等
一句话概括这套架构的取舍:
把“可信性”放到链上,把“灵活性”和“查询效率”留给链下。
这通常是 Web3 会员积分系统里,最稳、也最容易扩展的一条路径。