Web3 中级实战:基于智能合约与钱包登录构建去中心化会员积分系统
很多团队在做 Web3 产品时,第一反应往往是「发个 NFT 当会员卡」,或者「把用户行为写到链上就行」。但真到业务落地时,问题马上就来了:
- 用户怎么登录?总不能还要注册账号和密码吧?
- 积分到底要不要上链?全上链贵,不上链又不像 Web3
- 积分是可转让资产,还是只能做站内权益凭证?
- 后端怎么知道这个钱包地址真的是用户本人在操作?
- 一旦活动频繁,Gas 成本和吞吐量怎么控制?
这篇文章我不打算只讲概念,而是从架构设计视角,带你搭一套中级可落地的方案:钱包登录 + 链上积分合约 + 服务端签名校验 + 前端交互,实现一个去中心化会员积分系统。
重点不只是“能跑”,而是“为什么这样拆”。
背景与问题
传统会员积分系统的核心很清晰:身份、积分账户、积分变更、权益兑换。
到了 Web3 场景,这四件事都要重新定义:
1. 身份不再是用户名,而是钱包地址
用户不想注册密码,也不愿意把邮箱手机号当唯一凭证。最自然的身份入口,是钱包地址。
但地址本身不代表“已登录”,只有用户用私钥签名了一段挑战消息,服务端验证通过,才能证明“这个地址当前由这个用户控制”。
2. 积分不完全等于 Token
很多人上来就想用 ERC20 做积分,实际上不一定合适。
因为会员积分通常具备这些业务特征:
- 不希望自由交易
- 需要后台批量发放
- 需要活动场景做冻结、扣减、过期
- 更看重“可验证性”而不是“流动性”
所以,会员积分更适合做“不可转让的链上记账”,而不是一个完全开放的可转账代币。
3. 链上可信,链下灵活
如果所有积分变动都实时上链,确实最“去中心化”,但代价很高:
- Gas 成本高
- 前端交互慢
- 小额高频行为不划算
- 一旦活动运营复杂,链上逻辑容易膨胀
因此更合理的架构通常是:
- 链上保存高价值、可审计的积分余额或关键事件
- 链下处理高频业务逻辑与运营活动
- 用签名、事件日志和定时结算把两边串起来
这也是本文采用的思路。
方案目标与边界
先明确本文方案解决什么,不解决什么。
目标
我们要实现:
- 用户通过钱包完成登录
- 服务端生成登录 challenge,验证签名
- 部署一个积分合约,支持:
- 管理员加分
- 管理员扣分
- 查询用户积分
- 前端读取积分余额并展示
- 后端可基于业务事件触发合约积分更新
边界
本文不展开:
- NFT 会员卡与积分双系统联动
- 多链桥积分同步
- 零知识隐私积分
- DAO 治理积分投票模型
如果你要做大型积分经济体系,那是另一篇架构文章;本文聚焦在业务系统可上线的第一版骨架。
总体架构设计
先看组件拆分。
flowchart LR
U[用户]
FE[前端 DApp]
W[钱包<br/>MetaMask]
API[业务后端 API]
DB[(业务数据库)]
SC[积分智能合约]
CHAIN[区块链网络]
U --> FE
FE <--> W
FE --> API
API <--> DB
FE --> SC
API --> SC
SC --> CHAIN
这套架构里有两条主线:
- 身份主线:前端 + 钱包 + 后端签名校验
- 积分主线:后端业务触发 + 智能合约记账 + 前端读链展示
为什么前端和后端都可能与合约交互?
因为职责不同:
- 前端读链:查积分、查会员状态
- 后端写链:基于业务事件加分/扣分,更适合由受控钱包或 relayer 发起
这样做的好处是,用户体验更稳定。
比如“用户下单成功赠送 100 积分”,不应该要求用户自己再点一次钱包确认。这个动作本质上是平台业务行为,更适合由后端代为执行链上写入。
方案对比与取舍分析
在正式上代码前,先看几种常见实现。
方案 A:纯链上积分,用户自己发起每次变更
特点:
- 最去中心化
- 每次积分增减都需要用户签名
- 平台后端只做事件通知
优点:
- 用户完全掌握交互
- 数据可信度高
缺点:
- 体验差
- 高并发运营活动成本高
- 业务动作难以自动完成
适用场景:
- 高价值链上行为奖励
- 用户主动 claim 奖励
方案 B:后端中心化记账,链上仅做定期快照
特点:
- 链下数据库是主账本
- 周期性把结果同步到链上
优点:
- 成本低
- 灵活性强
- 适合频繁运营活动
缺点:
- 链上实时性弱
- 用户对平台信任要求高
适用场景:
- 业务初期验证
- 高频低价值积分
方案 C:链上余额 + 链下业务编排(本文方案)
特点:
- 钱包登录确权
- 后端校验业务事件后发起链上积分更新
- 链上保存关键余额与事件
优点:
- 审计性较好
- 用户体验较平衡
- 业务可控
缺点:
- 后端仍是重要信任点
- 需要处理链上失败重试、幂等等问题
适用场景:
- 会员成长体系
- 电商、内容、社区类 Web3 产品
- 想兼顾可信和上线效率的团队
我个人在做这类系统时,通常会优先选 C。因为 B 太中心化,A 太理想化,C 是真正能在预算、体验、可信性之间取得平衡的方案。
核心原理
这一节只讲最关键的几个机制。
1. 钱包登录:用签名证明地址控制权
流程如下:
- 前端请求后端生成随机 challenge
- 用户用钱包签名这段 challenge
- 后端用签名恢复地址
- 如果恢复出的地址和前端声称登录的地址一致,则登录成功
- 后端生成 JWT / Session,后续请求走传统鉴权
sequenceDiagram
participant U as 用户
participant FE as 前端
participant W as 钱包
participant API as 后端
U->>FE: 点击钱包登录
FE->>API: 请求 challenge(address)
API-->>FE: 返回 nonce/challenge
FE->>W: 发起签名
W-->>FE: 返回 signature
FE->>API: 提交 address + challenge + signature
API->>API: 恢复签名地址并校验
API-->>FE: 返回 JWT/Session
这个设计有两个关键点:
- challenge 必须一次性使用
- challenge 必须有过期时间
否则会有重放攻击风险。
2. 积分合约:做“受控记账”,而非自由转账
我们不直接用 ERC20,而是做一个简化版积分账本:
mintPoints(address, amount):管理员加分burnPoints(address, amount):管理员扣分balanceOf(address):查分- 禁止用户自行转账
这更贴近“会员积分”的业务本质。
3. 后端事件驱动发分
一个典型链下到链上的过程:
- 用户完成业务动作,如签到、下单、邀请
- 后端判断业务是否满足积分规则
- 写数据库事件表,生成一条待处理记录
- Worker 调用合约写链
- 成功后更新事件状态,并记录 txHash
这个“事件表 + 异步写链”的模式非常重要。
因为区块链交易不是同步数据库插入,它可能:
- Pending 很久
- 失败
- Revert
- 被替换
- 因 nonce 冲突而卡住
如果你把“业务成功”和“链上成功”强行绑成一次同步请求,后面一定会很痛苦。
4. 数据分层:链上存结果,链下存原因
建议这样拆:
链上存什么
- 用户积分余额
- 关键积分变更事件
- 管理员地址权限
链下存什么
- 业务来源(签到、订单、任务)
- 操作人、活动 ID、规则版本
- 幂等键
- 重试次数
- 交易状态与失败原因
这样做的好处是:
- 链上保持简洁
- 链下便于查询、筛选和运营分析
- 遇到异常能快速补偿
实战代码(可运行)
下面给一套最小可运行版本,包含:
- Solidity 积分合约
- Node.js 后端:challenge 登录校验 + 合约写入
- 前端示例:钱包登录 + 积分查询
一、智能合约:会员积分合约
使用 OpenZeppelin 的 Ownable 控制管理员权限。
contracts/MemberPoints.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
contract MemberPoints is Ownable {
mapping(address => uint256) private _balances;
event PointsMinted(address indexed user, uint256 amount, uint256 newBalance);
event PointsBurned(address indexed user, uint256 amount, uint256 newBalance);
constructor(address initialOwner) Ownable(initialOwner) {}
function balanceOf(address user) external view returns (uint256) {
return _balances[user];
}
function mintPoints(address user, uint256 amount) external onlyOwner {
require(user != address(0), "invalid user");
require(amount > 0, "amount must > 0");
_balances[user] += amount;
emit PointsMinted(user, amount, _balances[user]);
}
function burnPoints(address user, uint256 amount) external onlyOwner {
require(user != address(0), "invalid user");
require(amount > 0, "amount must > 0");
require(_balances[user] >= amount, "insufficient points");
_balances[user] -= amount;
emit PointsBurned(user, amount, _balances[user]);
}
}
二、Hardhat 部署
package.json
{
"name": "web3-member-points",
"version": "1.0.0",
"scripts": {
"compile": "hardhat compile",
"deploy": "hardhat run scripts/deploy.js --network localhost"
},
"dependencies": {
"@openzeppelin/contracts": "^5.0.2",
"ethers": "^6.13.1"
},
"devDependencies": {
"hardhat": "^2.22.10"
}
}
hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: "0.8.20",
networks: {
localhost: {
url: "http://127.0.0.1:8545"
}
}
};
scripts/deploy.js
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying with:", deployer.address);
const MemberPoints = await ethers.getContractFactory("MemberPoints");
const contract = await MemberPoints.deploy(deployer.address);
await contract.waitForDeployment();
console.log("MemberPoints deployed to:", await contract.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行
npm install
npx hardhat node
npx hardhat run scripts/deploy.js --network localhost
三、Node.js 后端:钱包登录与积分发放
这里用 Express + ethers。
为了方便演示,challenge 先放内存;生产环境请放 Redis 或数据库。
安装依赖
npm install express cors jsonwebtoken ethers
server.js
const express = require("express");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const { ethers } = require("ethers");
const app = express();
app.use(cors());
app.use(express.json());
const JWT_SECRET = "replace-this-in-production";
const PORT = 3001;
// 演示用:内存存储 challenge
const challenges = new Map();
// 部署后替换成你的 RPC、私钥、合约地址
const RPC_URL = "http://127.0.0.1:8545";
const PRIVATE_KEY = "0x59c6995e998f97a5a0044966f0945383b5f3d15f6c61f7e5a2c5c3ef6f1e0c5b";
const CONTRACT_ADDRESS = "YOUR_DEPLOYED_CONTRACT_ADDRESS";
const ABI = [
"function mintPoints(address user, uint256 amount) external",
"function burnPoints(address user, uint256 amount) external",
"function balanceOf(address user) external view returns (uint256)"
];
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, wallet);
function authMiddleware(req, res, next) {
const auth = req.headers.authorization || "";
const token = auth.replace("Bearer ", "");
if (!token) {
return res.status(401).json({ error: "missing token" });
}
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = payload;
next();
} catch (e) {
return res.status(401).json({ error: "invalid token" });
}
}
app.post("/auth/challenge", (req, res) => {
const { address } = req.body;
if (!address || !ethers.isAddress(address)) {
return res.status(400).json({ error: "invalid address" });
}
const nonce = Math.floor(Math.random() * 1e9).toString();
const challenge = `Login to MemberPoints\nAddress: ${address}\nNonce: ${nonce}\nTimestamp: ${Date.now()}`;
challenges.set(address.toLowerCase(), {
challenge,
expiresAt: Date.now() + 5 * 60 * 1000
});
res.json({ challenge });
});
app.post("/auth/verify", async (req, res) => {
const { address, signature } = req.body;
if (!address || !signature || !ethers.isAddress(address)) {
return res.status(400).json({ error: "invalid params" });
}
const record = challenges.get(address.toLowerCase());
if (!record) {
return res.status(400).json({ error: "challenge not found" });
}
if (Date.now() > record.expiresAt) {
challenges.delete(address.toLowerCase());
return res.status(400).json({ error: "challenge expired" });
}
try {
const recovered = ethers.verifyMessage(record.challenge, signature);
if (recovered.toLowerCase() !== address.toLowerCase()) {
return res.status(401).json({ error: "signature mismatch" });
}
challenges.delete(address.toLowerCase());
const token = jwt.sign(
{ address: address.toLowerCase() },
JWT_SECRET,
{ expiresIn: "2h" }
);
res.json({ token });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get("/points/me", authMiddleware, async (req, res) => {
try {
const balance = await contract.balanceOf(req.user.address);
res.json({
address: req.user.address,
points: balance.toString()
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// 演示接口:给当前用户加积分
app.post("/points/grant", authMiddleware, async (req, res) => {
const { amount } = req.body;
if (!amount || Number(amount) <= 0) {
return res.status(400).json({ error: "invalid amount" });
}
try {
const tx = await contract.mintPoints(req.user.address, Number(amount));
const receipt = await tx.wait();
res.json({
success: true,
txHash: receipt.hash,
amount: Number(amount)
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
四、前端:钱包登录与积分查询
下面用原生 HTML + JS 演示,避免框架噪音。
你接到 React、Next.js、Vue 里都很容易迁移。
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Member Points DApp</title>
</head>
<body>
<h2>去中心化会员积分系统</h2>
<button id="connectBtn">连接钱包并登录</button>
<button id="queryBtn">查询我的积分</button>
<button id="grantBtn">给我加 10 分</button>
<pre id="output"></pre>
<script type="module">
import { ethers } from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
const output = document.getElementById("output");
let token = localStorage.getItem("token") || "";
function log(data) {
output.textContent = typeof data === "string"
? data
: JSON.stringify(data, null, 2);
}
async function loginWithWallet() {
if (!window.ethereum) {
alert("请先安装 MetaMask");
return;
}
const provider = new ethers.BrowserProvider(window.ethereum);
const accounts = await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const address = accounts[0];
const challengeResp = await fetch("http://localhost:3001/auth/challenge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address })
});
const { challenge } = await challengeResp.json();
const signature = await signer.signMessage(challenge);
const verifyResp = await fetch("http://localhost:3001/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address, signature })
});
const data = await verifyResp.json();
token = data.token;
localStorage.setItem("token", token);
log({ address, token });
}
async function queryPoints() {
const resp = await fetch("http://localhost:3001/points/me", {
headers: {
"Authorization": `Bearer ${token}`
}
});
const data = await resp.json();
log(data);
}
async function grantPoints() {
const resp = await fetch("http://localhost:3001/points/grant", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({ amount: 10 })
});
const data = await resp.json();
log(data);
}
document.getElementById("connectBtn").onclick = loginWithWallet;
document.getElementById("queryBtn").onclick = queryPoints;
document.getElementById("grantBtn").onclick = grantPoints;
</script>
</body>
</html>
五、业务侧推荐的异步发分模型
如果你已经进入真实业务,不建议直接在接口里同步发链上交易,更推荐下面这种事件驱动方式。
flowchart TD
A[用户完成业务动作] --> B[业务服务校验规则]
B --> C[写入积分事件表 pending]
C --> D[异步 Worker 扫描待处理事件]
D --> E[调用合约 mint/burn]
E --> F{交易成功?}
F -- 是 --> G[记录 txHash + success]
F -- 否 --> H[记录失败原因 + 重试次数]
H --> I{超过阈值?}
I -- 否 --> D
I -- 是 --> J[人工介入/补偿]
事件表最少字段建议
| 字段 | 说明 |
|---|---|
| id | 事件唯一 ID |
| biz_id | 业务幂等键,如订单号 |
| wallet_address | 用户钱包地址 |
| change_type | grant / consume |
| amount | 分值 |
| status | pending / sent / confirmed / failed |
| tx_hash | 链上交易哈希 |
| retry_count | 重试次数 |
| error_message | 失败原因 |
| created_at | 创建时间 |
这个表非常关键。没有它,出了链上异常你几乎没法排查。
容量估算与成本思路
架构文章不能只讲功能,实际落地还要估算成本。
假设场景
- 日活:2 万
- 每日积分变更:10 万次
- 单次链上写入:一次
mintPoints或burnPoints
如果所有变更都直写主网,大概率不现实。
更推荐:
-
上 Layer2
- Polygon
- Arbitrum
- Base
- Optimism
-
做批量结算
- 高频行为先链下累计
- 每小时/每日统一结算到链上余额
-
按价值分层
- 高价值奖励实时上链
- 低价值运营积分延迟上链
一个经验判断
如果你的积分主要用于:
- 排行榜
- 连续签到
- 任务进度
- 低门槛权益兑换
那么完全没必要每笔都实时上链。
如果你的积分会影响:
- 可验证会员等级
- 链上凭证
- 多应用共享信用
- 社区治理权重
那就应提高链上同步比例。
常见坑与排查
这部分我尽量写得实战一点,都是非常容易踩的地方。
1. challenge 被重复使用
现象
用户第一次登录成功,第二次拿同样的签名还能过。
原因
后端没有在登录成功后销毁 challenge,或者 challenge 没有过期时间。
排查
- 查看 challenge 是否带 nonce
- 查看 verify 成功后是否删除 challenge
- 查看是否限制了 5 分钟内有效
处理
- challenge 一次性使用
- 加过期时间
- 建议存 Redis,并设置 TTL
2. 恢复地址和前端地址不一致
现象
前端显示签名成功,但后端总报 signature mismatch。
原因
- 签名消息文本不一致
- 前端/后端对换行符处理不一致
- 用户切换了钱包账户
- 使用了
personal_sign和signMessage的不同编码方式
排查
- 后端打印 challenge 原文
- 前端打印实际签名的原文
- 确认地址是否被切换
- 检查是否包含隐藏空格或换行
我以前就踩过“肉眼看一样、实际上多了一个换行”的坑,排了半天。
3. 后端私钥权限错误
现象
调用 mintPoints 时报 OwnableUnauthorizedAccount 或类似错误。
原因
写链的钱包地址不是合约 owner。
排查
- 部署时 owner 传的是谁
- 后端配置的私钥对应哪个地址
- 当前连接的是不是同一条链
处理
- 确保部署 owner 和后端 signer 一致
- 或增加
AccessControl做多角色权限管理
4. 交易发出后一直 pending
现象
接口卡住,或者 txHash 有了但长时间不确认。
原因
- Gas 设置太低
- 本地链/测试链节点异常
- nonce 冲突
- 同一钱包并发写链太多
排查
- 用区块浏览器查 txHash
- 检查钱包 nonce
- 看节点日志
- 检查是否多个 worker 共用一个 signer
处理
- 单 signer 串行发交易
- 做 nonce 管理
- 使用消息队列控制写链速率
5. 用户看到积分和后台不一致
现象
前端链上查到 100,后台业务系统显示 120。
原因
- 后台事件已生成但还未上链
- 链上交易失败但后台未回滚
- 读了错误网络
排查
- 检查事件表状态
- 检查 txHash 是否成功
- 检查前端钱包网络
- 检查后端 RPC 网络配置
处理
- UI 上区分“可用积分”和“待确认积分”
- 后台建立最终一致性视图
- 增加链上同步状态字段
安全/性能最佳实践
这部分决定系统能不能稳。
安全最佳实践
1. 不要把管理员私钥硬编码到代码里
本文代码为了演示写死了私钥,生产里绝对不要这样做。
至少要:
- 放环境变量
- 用 KMS / HSM 托管
- 或接入专用 signer 服务
2. challenge 登录必须防重放
建议 challenge 至少包含:
- 地址
- nonce
- 时间戳
- 域名/应用名
- 有效期
更进一步可以参考 SIWE(Sign-In with Ethereum)的消息格式。
3. 合约权限别只靠 Ownable 撑到底
当系统开始复杂后,建议用角色拆分:
POINTS_MINTER_ROLEPOINTS_BURNER_ROLEPAUSER_ROLEADMIN_ROLE
这样比单 owner 更安全,也更符合团队协作。
4. 关键业务要幂等
例如同一笔订单奖励 100 积分:
- 订单号必须是幂等键
- 重试时不能重复发分
这是 Web2 和 Web3 结合场景里最容易被忽视的问题之一。
5. 合约写链前要先做服务端规则校验
不要把所有规则都塞合约里。
复杂规则写在链上,成本高、难升级,还容易埋坑。
更合理的方式是:
- 链下判定业务规则
- 链上只做结果记录与最小约束
性能最佳实践
1. 查询尽量走只读 RPC,不要滥用写节点
前端查积分余额只需要 eth_call,完全没必要走昂贵写链流程。
2. 对热门地址做缓存
例如会员中心首页经常展示:
- 当前积分
- 会员等级
- 最近积分记录
可以做短时缓存,如 5~30 秒,显著减轻 RPC 压力。
3. 高并发发分使用异步队列
推荐结构:
- API 收请求
- 写事件表
- 投递消息队列
- Worker 统一写链
这样链上抖动不会直接拖垮业务接口。
4. 事件日志优先于全量链扫描
如果你要同步积分变更,不要每次去扫全链状态。
优先订阅和消费合约事件:
PointsMintedPointsBurned
这样更稳定,也更便于审计。
可演进架构建议
如果你准备从 MVP 往正式产品升级,我建议按下面路径演进。
stateDiagram-v2
[*] --> MVP
MVP --> BizAsync
BizAsync --> RoleControl
RoleControl --> L2Deploy
L2Deploy --> BatchSettlement
BatchSettlement --> MultiAppSharing
state MVP {
[*] --> 钱包登录
钱包登录 --> 单合约积分
}
state BizAsync {
[*] --> 事件表
事件表 --> Worker写链
}
state RoleControl {
[*] --> 多角色权限
多角色权限 --> 暂停机制
}
state L2Deploy {
[*] --> 迁移至低Gas链
}
state BatchSettlement {
[*] --> 高频链下累计
高频链下累计 --> 周期上链
}
state MultiAppSharing {
[*] --> 跨应用积分身份
}
推荐演进顺序
- 先把钱包登录和积分合约跑通
- 再补事件表、幂等、重试
- 然后再做多角色和监控告警
- 最后考虑批处理和多应用共享
不要一开始就想做“全宇宙最标准的 Web3 会员系统”,那通常只会拖慢上线。
总结
基于智能合约与钱包登录构建去中心化会员积分系统,核心不是“把积分写上链”这么简单,而是要把三件事协调好:
- 身份可信:用钱包签名完成无密码登录
- 积分可信:用链上合约做关键余额与事件记账
- 业务可控:用后端事件驱动、幂等和重试保证稳定落地
如果你是中级开发者,我建议你按下面顺序动手:
- 第一步:先实现 challenge 签名登录
- 第二步:部署一个最小积分合约
- 第三步:后端打通
grant points流程 - 第四步:引入事件表和异步 worker
- 第五步:补安全、权限、监控
最后给一个很实用的边界建议:
- 低价值、高频积分:优先链下累计,批量上链
- 高价值、可审计权益:优先实时上链
- 需要强交易属性的积分:再考虑 ERC20 化
- 只是会员成长值:不要过度金融化
Web3 不是把所有东西都搬到链上,而是把真正需要可信的部分搬上去。
把这个边界想清楚,你的会员积分系统就不会既贵又难维护。