Web3 中级实战:基于智能合约与钱包登录构建一套可落地的链上会员积分系统
很多团队一提“链上会员系统”,第一反应是:把用户积分直接写进智能合约不就完了?
但真开始做,你很快会遇到一堆现实问题:
- 用户怎么登录?是不是必须邮箱+密码?
- 钱包地址和会员身份怎么绑定?
- 所有积分变化都上链,Gas 成本能不能接受?
- 后端还能不能做风控、活动审计和补发?
- 前端展示“我的积分”时,是读链还是读库?
- 如果以后要接 NFT 等级、权益兑换、任务系统,架构还能不能扩?
这篇文章我不打算只讲“怎么写一个积分合约”,而是从架构落地的角度,带你搭一套能上线、可扩展、能排错的链上会员积分系统。读完后你应该能自己做出一个中级可用版本。
背景与问题
传统会员积分系统通常依赖中心化数据库:
用户注册账号,服务端记录积分,后台定时发券、做等级成长。
到了 Web3 场景,至少有三个变化:
-
身份入口从账号密码变成钱包 用户未必愿意注册,更多是“连接钱包即登录”。
-
资产与权益天然要求可验证 比如会员等级、徽章、积分变动记录,希望能公开验证,避免“平台说了算”。
-
链上与链下必须协同 并不是所有数据都适合上链。行为日志、任务完成明细、风控结果,更适合放在链下。
所以,一个真正可落地的方案,通常不是“全上链”,而是:
- 钱包做身份入口
- 智能合约做可信积分账本或结算层
- 后端做任务计算、签名授权、风控和索引
- 前端统一展示链上状态和链下业务态
典型业务诉求
我们先把需求压缩成一个中等复杂度版本:
- 用户通过 MetaMask 登录
- 后端验证钱包签名,建立会话
- 用户完成任务后,可领取积分
- 积分发放必须可审计,防止后端随便改
- 支持查询当前积分余额
- 后续可扩展会员等级、权益兑换、NFT 徽章
这个问题的本质,不是“写一个 mapping(address => uint256)”,而是设计可信边界。
方案概览:为什么推荐“链下计算,链上结算”
我比较推荐中级项目采用这套思路:
- 登录:钱包签名登录(SIWE 风格或自定义 nonce)
- 任务判断:链下完成
- 积分发放授权:后端签名
- 积分记账:用户调用合约领取,合约验证签名后铸记积分
- 展示查询:前端读链 + 后端聚合补充信息
这么做的原因很现实:
方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 全链上任务与积分 | 最透明 | 开发复杂、Gas 高、灵活性差 | 极简游戏规则、纯链上行为 |
| 全链下积分系统 | 成本低、实现快 | 缺乏可信性,用户不完全信任 | Web2 迁移试水 |
| 链下计算 + 链上结算 | 成本与可信度平衡 | 增加签名与校验复杂度 | 大多数会员/积分/任务平台 |
这篇文章就按第三种方案来做。
整体架构
flowchart LR
U[用户钱包] --> F[前端 DApp]
F --> W[钱包签名登录]
F --> B[业务后端]
B --> DB[(任务/订单/风控数据库)]
B --> S[签名服务]
F --> C[积分智能合约]
S --> F
C --> I[链上事件索引器]
I --> B
这张图里最关键的是两个动作:
- 登录签名:证明“这个地址就是我”
- 领取签名:证明“后端认可你这次可以拿多少积分”
注意,这两个签名不是一回事,千万别混用。
核心原理
1. 钱包登录:用签名替代密码
传统系统靠密码证明身份,Web3 更常见的是:
- 后端生成 nonce
- 前端让钱包对消息签名
- 后端验签,确认地址所有权
- 验签通过后,签发 session/JWT
核心点有两个:
- 消息必须带 nonce,防止重放
- nonce 必须一次性使用
登录时序
sequenceDiagram
participant U as 用户
participant F as 前端
participant B as 后端
participant M as 钱包
F->>B: 请求登录 nonce
B-->>F: 返回 nonce
F->>M: 发起签名
M-->>F: 返回 signature
F->>B: 提交 address + nonce + signature
B->>B: 验签并销毁 nonce
B-->>F: 返回 session/JWT
如果没有 nonce,一条旧签名可能被重复利用,这就是经典重放攻击入口。
2. 积分发放:后端授权,合约验签执行
任务逻辑通常在后端,比如:
- 连续签到 7 天
- 购买某商品
- 绑定社媒账号
- 完成邀请任务
- 持有某 NFT 或参与某次链上交互
这些判断结果往往要结合数据库、活动规则、风控系统,适合链下完成。
但如果完全由后端改数据库积分,用户又不够放心。
所以更好的做法是:
- 后端根据业务规则生成一份“积分领取授权”
- 使用平台私钥对授权数据签名
- 用户把授权和签名提交到合约
- 合约验证签名合法后执行记账
领取积分时序
sequenceDiagram
participant U as 用户
participant F as 前端
participant B as 后端
participant C as 积分合约
U->>F: 点击领取积分
F->>B: 请求 claim 授权
B->>B: 校验任务、风控、去重
B-->>F: amount + claimId + deadline + signature
F->>C: claim(amount, claimId, deadline, signature)
C->>C: 验签/去重/过期检查
C-->>F: 领取成功
为什么要有 claimId 和 deadline
claimId:防止同一份授权被重复领取deadline:限制授权有效期,降低泄漏风险
3. 链上数据设计:最小可信集
很多人一开始喜欢把所有任务明细都上链,这通常没必要。
对于会员积分系统,建议只把最关键的可验证状态放上链:
- 用户累计积分余额
- 已使用的 claimId
- 管理员签名验证公钥/地址
- 必要的事件日志
而这些数据尽量保持简单,合约就会更稳、更省 Gas。
一个合理的数据边界
classDiagram
class MembershipPoints {
+owner : address
+signer : address
+balances(address) uint256
+usedClaims(bytes32) bool
+claim(amount, claimId, deadline, signature)
+setSigner(address)
+balanceOf(address) uint256
}
实战代码(可运行)
下面给一个可运行的最小实现:
- 合约:Solidity,基于 OpenZeppelin
- 后端:Node.js + Express + ethers
- 前端调用:ethers.js 示例
为了让文章聚焦,我把它做成一个“可领取积分的链上积分账本”。
合约实现:MembershipPoints.sol
说明:这里我们不直接用 ERC20,因为很多积分系统并不希望积分可自由转账。
会员积分更多是“账户状态”,不是通用代币。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
contract MembershipPoints is Ownable {
using ECDSA for bytes32;
mapping(address => uint256) private _balances;
mapping(bytes32 => bool) public usedClaims;
address public signer;
event PointsClaimed(address indexed user, uint256 amount, bytes32 indexed claimId);
event SignerUpdated(address indexed oldSigner, address indexed newSigner);
constructor(address initialOwner, address initialSigner) Ownable(initialOwner) {
require(initialSigner != address(0), "invalid signer");
signer = initialSigner;
}
function setSigner(address newSigner) external onlyOwner {
require(newSigner != address(0), "invalid signer");
address old = signer;
signer = newSigner;
emit SignerUpdated(old, newSigner);
}
function balanceOf(address user) external view returns (uint256) {
return _balances[user];
}
function claim(
uint256 amount,
bytes32 claimId,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "signature expired");
require(!usedClaims[claimId], "claim already used");
bytes32 digest = keccak256(
abi.encodePacked(
block.chainid,
address(this),
msg.sender,
amount,
claimId,
deadline
)
);
bytes32 ethSigned = MessageHashUtils.toEthSignedMessageHash(digest);
address recovered = ECDSA.recover(ethSigned, signature);
require(recovered == signer, "invalid signature");
usedClaims[claimId] = true;
_balances[msg.sender] += amount;
emit PointsClaimed(msg.sender, amount, claimId);
}
}
Hardhat 部署脚本
scripts/deploy.js
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
// 演示用:实际项目请改成后端签名地址
const signerAddress = deployer.address;
const MembershipPoints = await ethers.getContractFactory("MembershipPoints");
const contract = await MembershipPoints.deploy(deployer.address, signerAddress);
await contract.waitForDeployment();
console.log("MembershipPoints deployed to:", await contract.getAddress());
console.log("Owner:", deployer.address);
console.log("Signer:", signerAddress);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: "0.8.20",
};
安装与运行
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
npx hardhat
npx hardhat compile
npx hardhat run scripts/deploy.js --network hardhat
后端实现:登录验签与积分授权签名
这里用 Express 做一个最小后端,包含两个核心接口:
/auth/nonce:生成登录 nonce/auth/verify:验签登录/points/claim-signature:生成积分领取签名
server.js
const express = require("express");
const crypto = require("crypto");
const { ethers } = require("ethers");
const app = express();
app.use(express.json());
// 演示用内存存储,生产环境请换 Redis/DB
const nonces = new Map();
const sessions = new Map();
const claimedTasks = new Set();
// 用于给 claim 授权签名的服务端私钥
// 请替换成测试私钥,绝不要用真实主网热钱包私钥直接写死
const SIGNER_PRIVATE_KEY = "0x59c6995e998f97a5a0044966f094538e0d7ef4d3f8b0f5f2ff1f908db8b27c59";
const signerWallet = new ethers.Wallet(SIGNER_PRIVATE_KEY);
// 模拟合约地址,生产环境应配置真实部署地址
const CONTRACT_ADDRESS = "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC";
// 1) 获取登录 nonce
app.post("/auth/nonce", (req, res) => {
const { address } = req.body;
if (!address || !ethers.isAddress(address)) {
return res.status(400).json({ error: "invalid address" });
}
const nonce = crypto.randomBytes(16).toString("hex");
nonces.set(address.toLowerCase(), nonce);
res.json({
address,
nonce,
message: `Login to Membership System\nAddress: ${address}\nNonce: ${nonce}`,
});
});
// 2) 验证登录签名
app.post("/auth/verify", async (req, res) => {
const { address, signature } = req.body;
const lower = String(address || "").toLowerCase();
const nonce = nonces.get(lower);
if (!nonce) {
return res.status(400).json({ error: "nonce not found" });
}
const message = `Login to Membership System\nAddress: ${address}\nNonce: ${nonce}`;
try {
const recovered = ethers.verifyMessage(message, signature);
if (recovered.toLowerCase() !== lower) {
return res.status(401).json({ error: "signature invalid" });
}
nonces.delete(lower);
const token = crypto.randomBytes(24).toString("hex");
sessions.set(token, { address: lower, loginAt: Date.now() });
res.json({ token, address: lower });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// 简单 session 中间件
function auth(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token || !sessions.has(token)) {
return res.status(401).json({ error: "unauthorized" });
}
req.user = sessions.get(token);
next();
}
// 3) 生成积分 claim 签名
app.post("/points/claim-signature", auth, async (req, res) => {
const userAddress = req.user.address;
const { taskId } = req.body;
if (!taskId) {
return res.status(400).json({ error: "taskId required" });
}
// 模拟任务去重:同一个地址同一个任务只允许一次
const taskKey = `${userAddress}:${taskId}`;
if (claimedTasks.has(taskKey)) {
return res.status(400).json({ error: "task already claimed" });
}
// 模拟业务判断:任务积分
const amount = 100;
const claimId = ethers.keccak256(
ethers.toUtf8Bytes(`${userAddress}:${taskId}:${Date.now()}`)
);
const deadline = Math.floor(Date.now() / 1000) + 10 * 60;
// 与合约中的 abi.encodePacked 对齐
const digest = ethers.solidityPackedKeccak256(
["uint256", "address", "address", "uint256", "bytes32", "uint256"],
[
31337, // 本地 hardhat chainId
CONTRACT_ADDRESS,
userAddress,
amount,
claimId,
deadline,
]
);
const signature = await signerWallet.signMessage(ethers.getBytes(digest));
claimedTasks.add(taskKey);
res.json({
userAddress,
amount,
claimId,
deadline,
signature,
signer: signerWallet.address,
});
});
app.listen(3001, () => {
console.log("Server running at http://localhost:3001");
});
安装依赖
npm install express ethers
node server.js
前端调用示例
下面示例演示三个步骤:
- 连接钱包
- 签名登录
- 获取 claim 签名并调用合约领取积分
frontend.js
import { ethers } from "ethers";
const CONTRACT_ADDRESS = "你的合约地址";
const ABI = [
"function claim(uint256 amount, bytes32 claimId, uint256 deadline, bytes signature) external",
"function balanceOf(address user) external view returns (uint256)"
];
async function connectWallet() {
if (!window.ethereum) throw new Error("MetaMask not found");
const provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const address = await signer.getAddress();
return { provider, signer, address };
}
async function login(address, signer) {
const nonceResp = await fetch("http://localhost:3001/auth/nonce", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address })
});
const nonceData = await nonceResp.json();
const signature = await signer.signMessage(nonceData.message);
const verifyResp = await fetch("http://localhost:3001/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address, signature })
});
return verifyResp.json();
}
async function claimPoints(signer, token) {
const claimResp = await fetch("http://localhost:3001/points/claim-signature", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({ taskId: "task-001" })
});
const claimData = await claimResp.json();
if (claimData.error) throw new Error(claimData.error);
const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, signer);
const tx = await contract.claim(
claimData.amount,
claimData.claimId,
claimData.deadline,
claimData.signature
);
await tx.wait();
console.log("claim success:", tx.hash);
const user = await signer.getAddress();
const balance = await contract.balanceOf(user);
console.log("points balance:", balance.toString());
}
async function main() {
const { signer, address } = await connectWallet();
const loginResult = await login(address, signer);
console.log("login result:", loginResult);
await claimPoints(signer, loginResult.token);
}
main().catch(console.error);
关键设计取舍
到这里,你已经有一套可工作的最小系统了。
但真正做架构时,重点不是“能跑”,而是“以后不会推倒重来”。
1. 为什么不把积分做成可转账 ERC20
很多会员积分不希望具备金融属性,原因很直接:
- 可转账后,积分会被交易和刷量
- 会员等级逻辑容易被套利
- 合规边界会变复杂
如果你的积分只是权益凭证,不可转账账本通常更合适。
如果以后要做兑换市场,可以再单独设计代币层,而不是一开始就上 ERC20。
2. 为什么 claim 要由用户自己提交,而不是后端代发
有两种模式:
- 后端直接调用合约发积分
- 后端签名,用户自己 claim
我更推荐第二种,原因是:
- 用户自己支付 Gas,平台成本可控
- 用户能明确感知“这次积分到账是链上操作”
- 后端不用托管大量链上交易发送逻辑
当然,如果你要追求极致体验,也可以配合 meta transaction 或 ERC-2771 做平台代付 Gas。
3. 为什么后端还需要数据库
因为链上不是万能业务库。
你仍然需要数据库来记录:
- 任务完成明细
- 活动配置
- 风控标记
- 登录会话
- 索引缓存
- 审计日志
一个很常见的误区是“既然是 Web3,就不要后端”。
真实业务里,没有后端你几乎做不了完整会员系统。
容量估算与扩展思路
对于中级项目,我建议提前考虑三个规模节点:
阶段 A:冷启动期(< 1 万用户)
- 单体后端 + Redis + PostgreSQL 足够
- 使用链上事件回写数据库
- 手动运营活动可接受
阶段 B:增长期(1 万 ~ 50 万用户)
- 将签名服务独立
- 增加消息队列处理任务结算
- 引入链上索引器或 The Graph 类方案
- 积分展示页尽量用缓存
阶段 C:高并发活动期
- 批量签名或 Merkle claim
- 多链部署或 L2 迁移
- 热门活动任务异步化
- 合约逻辑尽量冻结,复杂规则留在链下
如果你预计活动领取非常频繁,单笔签名 claim 也会有瓶颈。这时可以升级为:
- Merkle Root 批次空投
- 按活动批次上链 root
- 用户用 proof 自助领取
这样更适合海量发放场景,但实现复杂度也会升高。
常见坑与排查
这一节我尽量讲点真会踩到的坑。
1. 合约验签总失败
常见原因
- 前后端
chainId不一致 - 合约地址传错
abi.encodePacked与后端solidityPackedKeccak256类型顺序不一致- 前端传的是别人的登录 token
- 签名的是原始 digest,合约却按
toEthSignedMessageHash验证
排查建议
先把以下内容全部打印出来对比:
- chainId
- contract address
- user address
- amount
- claimId
- deadline
- digest
- recovered signer
如果这些字段有一个不一致,验签就必然失败。
2. 登录明明签了名,后端却验不过
常见原因
- 钱包签名的 message 和后端拼接的 message 不完全一致
- 地址大小写处理不统一
- nonce 已经被消费
- 用户切换了钱包账号
排查建议
后端保存完整原始 message,前端签什么,后端就验什么。
不要在前端签完后,后端再“重新拼一个看起来一样的字符串”,很容易因为换行符或空格不同导致失败。
3. 同一任务被重复领取
常见原因
- 只在数据库做去重,没有在链上做
claimId防重 claimId生成规则不稳定- 后端并发下重复发放签名
排查建议
必须双重防重:
- 链下:任务维度唯一约束
- 链上:
usedClaims[claimId]
只做一层,迟早出问题。
4. 前端显示积分和链上余额对不上
常见原因
- 前端展示的是数据库缓存,不是链上实时值
- 交易已发送但未确认
- 链上事件索引延迟
- 用户切换网络后读了错误链
排查建议
给用户明确区分:
- “待确认”
- “已上链”
- “已索引同步”
别把所有状态混成一个“已到账”,否则运营和客服很难处理。
5. 签名私钥泄漏风险被低估
这是我比较想强调的一点。
很多项目 demo 阶段直接把签名私钥放在后端配置里,后来一路带到生产环境,非常危险。
建议
- 使用专门签名服务
- 最好接 HSM/KMS
- 定期轮换 signer
- 合约支持
setSigner - 给每个环境使用不同 signer
安全最佳实践
1. 严格区分“登录签名”和“业务签名”
这两个签名的用途不同、生命周期不同、风险不同:
- 登录签名:证明地址控制权
- 业务签名:证明平台授权结果
不要拿登录签名去直接发积分,也不要把 claim 授权当登录凭证。
2. 给 claim 增加过期时间和域隔离
本文示例里做了三层隔离:
block.chainidaddress(this)deadline
这样做可以避免:
- 同一签名跨链复用
- 同一签名跨合约复用
- 长期有效签名泄漏后被滥用
如果你想做得更规范,可以进一步采用 EIP-712 typed data。
3. 权限最小化
合约内建议只保留必要权限:
- owner:管理 signer
- signer:签发 claim 授权
不要让 owner 直接随意修改所有用户积分,除非你明确接受中心化治理。
如果业务必须支持人工补发,建议走“补发 claim”流程,而不是“后台硬改余额”。
4. 防刷与风控不要幻想交给合约
合约只能验证规则,不能理解复杂业务风险。
像这些事情,仍然要在链下做:
- 女巫攻击检测
- 设备/IP 异常
- 多账号套利
- 任务脚本刷量
- 黑名单控制
链上可信不等于抗滥用。
5. 事件日志必须设计好
至少要有:
PointsClaimed(user, amount, claimId)SignerUpdated(oldSigner, newSigner)
这样你后续做:
- 区块链浏览器排查
- 数据索引
- 审计回放
- 运营对账
都会轻松很多。
性能最佳实践
1. 读多写少时,前端优先走聚合接口
会员系统的典型特点是:
- 查询多
- 发放相对少
- 页面经常要同时展示积分、等级、任务进度、权益状态
如果每次都让前端直接发十几个 RPC 请求,体验会很差。
更实际的方案是:
- 关键余额读链
- 复杂页面走后端聚合接口
- 后端用索引缓存提升响应速度
2. 大规模发积分考虑批量证明,而不是逐条签名
当活动规模很大,比如一次给 10 万用户发积分时:
- 逐个生成 claim 签名,成本高
- 用户逐个领取,体验也一般
这时应考虑:
- Merkle 树批量分发
- 活动 root 上链
- 用户携 proof 领取
它不一定适合所有项目,但一旦活动频次高、名单大,这条路几乎是必走的。
3. 选链要贴合积分业务,而不是追热点
如果积分系统交互频繁,主网通常不是首选。
更适合考虑:
- Polygon
- Arbitrum
- Base
- Optimism
- 其他低 Gas EVM 链
选择标准很简单:
- 钱包兼容性
- Gas 成本
- 基础设施成熟度
- 你现有用户在哪条链
进一步扩展:会员等级、权益兑换、NFT 勋章
当积分账本稳定后,通常会继续长出三类能力:
1. 会员等级
可根据积分区间或成长值计算:
- Bronze
- Silver
- Gold
- Platinum
实现方式有两种:
- 链上实时计算等级
- 链下计算后上链记录结果
前者透明,后者更灵活。
2. 权益兑换
比如 1000 积分兑换一次优惠券。
这时你需要在合约里引入“扣减积分”能力,或者把兑换动作留在链下再做链上登记。
3. NFT 勋章
满足条件时铸造 NFT,作为会员成就或身份展示。
这是链上会员系统很常见的增强层,因为 NFT 非常适合做“可展示的荣誉凭证”。
一个更稳妥的落地建议
如果你准备真的上线,我建议按这个顺序推进:
- 先做钱包登录 + nonce 验签
- 再做不可转账积分账本
- 接入后端 claim 授权签名
- 补齐事件索引和运营后台
- 最后再考虑等级、兑换、NFT
不要一上来就想把积分、等级、商城、徽章、邀请裂变全做了。
Web3 项目的复杂度,往往不是卡在合约,而是卡在身份、风控、状态同步和运营排错。
总结
一套可落地的链上会员积分系统,核心不是“把积分放到链上”,而是设计清楚这三件事:
- 谁在证明用户身份:钱包签名登录
- 谁在决定积分是否应该发:链下业务与风控
- 谁在保证积分发放可验证:链上合约验签结算
如果你是中级开发者,我建议你先采用本文这套基线方案:
- 钱包登录:nonce + 验签
- 积分发放:后端签名授权 + 合约 claim
- 数据协同:链上账本 + 链下索引与业务库
- 风险控制:claimId 防重 + deadline 过期 + signer 可轮换
它的边界也很明确:
- 适合会员、任务、成长值、权益体系
- 不适合一开始就做高频金融化积分交易
- 用户量暴涨后,需要升级为批量证明与更强索引架构
如果你现在正准备做一个 Web3 会员系统,不妨先把最小闭环跑通:
连接钱包 → 签名登录 → 完成任务 → 获取授权 → 链上领取积分 → 前端展示余额。
这条链路一旦打通,后面的等级、兑换、勋章,都会顺很多。