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

《Web3 中级实战:从零搭建基于智能合约的钱包登录与链上身份认证系统-436》

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

Web3 中级实战:从零搭建基于智能合约的钱包登录与链上身份认证系统

很多人第一次做 Web3 登录,都会把它理解成“让用户用 MetaMask 点一下签名,然后后端验签发个 token”。这当然没错,但如果你想把它真正做成可扩展、可审计、可接入业务系统的链上身份认证方案,事情就没那么简单了。

这篇文章我会从架构视角出发,带你搭一个中级可用的系统:
钱包登录 + 签名认证 + 智能合约身份绑定 + 后端会话签发

重点不是“签名能不能验过”,而是:

  • 如何把“钱包地址”提升为“可管理的身份”
  • 如何设计链上与链下的职责边界
  • 如何避免重放攻击、签名混淆、链切换、代理钱包兼容这些常见坑
  • 如何落到一套可以运行的代码

背景与问题

在传统 Web2 里,身份通常来自手机号、邮箱、用户名密码,认证依赖中心化数据库。
但在 Web3 里,最自然的身份载体是钱包地址

问题也随之出现:

  1. 钱包地址不等于完整身份

    • 地址只能证明“我持有私钥”
    • 不能天然表达昵称、角色、业务账号映射、权限状态
  2. 纯前端签名登录不够用

    • 能登录,不代表可接入后端业务
    • 缺少统一 session 管理、权限控制、风控入口
  3. 只靠后端数据库会丢失 Web3 特征

    • 地址与用户关系如果只在中心化库里,跨应用复用性差
    • 某些身份关系更适合公开、可验证、链上可组合
  4. 直接把所有认证逻辑放链上又不现实

    • 成本高
    • 延迟高
    • 不适合频繁登录态校验

所以,一个更实用的方案通常是:

钱包签名做“身份持有证明”,智能合约做“身份关系登记”,后端做“会话和业务权限承载”。

这套模式兼顾了去中心化身份的可验证性,也保留了业务系统需要的性能与灵活性。


目标架构

我们先明确这套系统要实现什么:

  • 用户用钱包发起登录
  • 服务端生成一次性 nonce
  • 用户签名 nonce
  • 服务端验签确认地址归属
  • 若用户尚未注册,则调用智能合约绑定一个链上 profile
  • 服务端签发 JWT / Session
  • 后续接口通过 session 识别用户
  • 必要时可从链上读取身份状态进行二次校验

系统分层

flowchart LR
  U[用户钱包] --> F[前端 DApp]
  F --> B[认证后端]
  B --> DB[(Nonce/Session DB)]
  B --> RPC[区块链 RPC]
  RPC --> C[IdentityRegistry 合约]
  B --> Biz[业务服务]

这张图的关键点在于:
登录动作发生在钱包与后端之间,身份登记发生在后端与合约之间。

职责划分建议

组件职责
前端 DApp连接钱包、请求 nonce、触发签名、提交签名结果
后端认证服务nonce 管理、验签、会话签发、链上身份同步
智能合约地址与身份 ID 绑定、身份元数据哈希、状态管理
数据库保存 nonce、用户快照、会话信息、审计日志
业务服务根据 session 和链上身份状态决定授权

方案对比与取舍分析

做钱包登录时,常见有三种实现思路。

方案一:纯链下签名登录

流程很简单:服务端发 nonce,客户端签名,服务端验签。

优点:

  • 成本低
  • 实现快
  • 不需要链上交易

缺点:

  • 身份关系只在中心化数据库里
  • 跨应用复用差
  • 缺少公开可验证的身份登记层

适合:

  • MVP
  • 内部工具
  • 对链上身份要求不高的产品

方案二:纯链上认证

让用户通过链上交易完成登录或认证状态变更。

优点:

  • 强可验证
  • 状态公开透明

缺点:

  • 用户体验差
  • 每次认证成本高
  • 延迟大,不适合频繁调用

适合:

  • 高可信链上流程
  • 低频高价值认证场景

方案三:链下登录 + 链上身份绑定

也就是本文的方案。

优点:

  • 登录体验好
  • 业务接口仍然高性能
  • 身份关系可公开验证
  • 易于扩展权限、角色、资料绑定

缺点:

  • 架构复杂度中等
  • 需要维护链上链下一致性

适合:

  • 中级以上 Web3 应用
  • 需要“登录 + 业务账号体系 + 链上身份可验证”的系统

核心原理

核心原理其实可以拆成三层:

  1. 地址归属证明
  2. 身份绑定
  3. 会话承载

1. 地址归属证明:靠签名,不靠密码

服务端生成一个 nonce,比如:

Login to Demo DApp
Address: 0xabc...
Nonce: 2f7d8c...
ChainId: 11155111
IssuedAt: 2024-01-01T12:00:00Z

用户用钱包签名这段消息,服务端验签后就能知道:

  • 这个签名确实由该地址持有者发起
  • 这个登录请求是针对当前系统的
  • 这个 nonce 没被重复使用

2. 身份绑定:靠合约记录“地址 -> profile”

仅有地址是不够的。我们通常希望有一个稳定身份对象,例如:

  • userId / profileId
  • metadataURI 或 metadataHash
  • 激活/冻结状态
  • 角色、等级、KYC 标记等

这些关系可以放到合约里维护。

classDiagram
  class IdentityRegistry {
    +register(address user, bytes32 profileId, string metadataURI)
    +isRegistered(address user) bool
    +getProfile(address user) bytes32
    +setStatus(address user, uint8 status)
  }

3. 会话承载:靠后端 JWT / Session

钱包签名适合做“登录入口”,但不适合每个 API 都重新签一次。
因此通常在首次验签成功后,由后端签发 JWT:

  • sub: 钱包地址
  • profileId: 链上身份 ID
  • chainId
  • roles
  • exp

后续业务接口只验证 JWT 即可,必要时再补一次链上状态校验。


认证时序

sequenceDiagram
  participant U as 用户钱包
  participant F as 前端
  participant B as 后端
  participant C as IdentityRegistry

  F->>B: GET /auth/nonce?address=0x...
  B-->>F: nonce + message
  F->>U: 请求钱包签名
  U-->>F: signature
  F->>B: POST /auth/verify {address, message, signature}
  B->>B: 验签 + 校验 nonce
  alt 未注册
    B->>C: register(address, profileId, metadataURI)
    C-->>B: tx success
  end
  B-->>F: JWT + profile

这个流程里我建议你特别注意两点:

  • nonce 必须一次性使用
  • 链上注册最好异步化或加幂等控制

否则在高并发或用户重复点击时,很容易出现“登录成功但注册重复提交”的问题。


智能合约设计

下面给一个可运行的基础版本,使用 Solidity 0.8.x。
这个合约的目标不是做复杂 DID,而是作为一个轻量 Identity Registry

合约代码

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

contract IdentityRegistry {
    enum Status {
        None,
        Active,
        Suspended
    }

    struct Profile {
        bytes32 profileId;
        string metadataURI;
        Status status;
        uint256 createdAt;
    }

    mapping(address => Profile) private profiles;
    address public owner;

    event IdentityRegistered(address indexed user, bytes32 indexed profileId, string metadataURI);
    event IdentityStatusUpdated(address indexed user, Status status);
    event MetadataUpdated(address indexed user, string metadataURI);

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    modifier onlyRegistered(address user) {
        require(profiles[user].status != Status.None, "not registered");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function register(
        address user,
        bytes32 profileId,
        string calldata metadataURI
    ) external onlyOwner {
        require(user != address(0), "zero address");
        require(profiles[user].status == Status.None, "already registered");

        profiles[user] = Profile({
            profileId: profileId,
            metadataURI: metadataURI,
            status: Status.Active,
            createdAt: block.timestamp
        });

        emit IdentityRegistered(user, profileId, metadataURI);
    }

    function setStatus(address user, Status status)
        external
        onlyOwner
        onlyRegistered(user)
    {
        profiles[user].status = status;
        emit IdentityStatusUpdated(user, status);
    }

    function updateMetadata(address user, string calldata metadataURI)
        external
        onlyOwner
        onlyRegistered(user)
    {
        profiles[user].metadataURI = metadataURI;
        emit MetadataUpdated(user, metadataURI);
    }

    function isRegistered(address user) external view returns (bool) {
        return profiles[user].status != Status.None;
    }

    function getProfile(address user)
        external
        view
        returns (
            bytes32 profileId,
            string memory metadataURI,
            Status status,
            uint256 createdAt
        )
    {
        Profile memory p = profiles[user];
        return (p.profileId, p.metadataURI, p.status, p.createdAt);
    }
}

设计说明

这个版本用了最简单的 owner 模式,适合 demo 和中小型项目。
真实项目里,你可以进一步升级为:

  • AccessControl 做角色管理
  • 多签控制 register 权限
  • 支持一个地址绑定多个身份
  • 支持 EIP-712 授权注册
  • metadata 只存哈希,实际资料放 IPFS / Arweave

实战代码(可运行)

下面用一套最小可运行方案来串起来:

  • 合约:Solidity
  • 后端:Node.js + Express + ethers
  • 前端:原生 HTML + ethers.js

为了让你能直接试,我会尽量写成“拷贝就能改”的风格。


一、后端:认证服务

1. 安装依赖

mkdir web3-auth-demo
cd web3-auth-demo
npm init -y
npm install express cors jsonwebtoken ethers dotenv

2. 创建 .env

PORT=3000
JWT_SECRET=replace_this_with_a_long_random_string
RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY
PRIVATE_KEY=YOUR_SERVER_WALLET_PRIVATE_KEY
CONTRACT_ADDRESS=0xYourContractAddress
CHAIN_ID=11155111

这里的 PRIVATE_KEY 是后端用于调用合约注册身份的服务钱包。
生产环境千万不要明文放机器里,至少接 KMS 或密钥托管。

3. 编写 server.js

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

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

const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET;
const CHAIN_ID = Number(process.env.CHAIN_ID);

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const signer = new ethers.Wallet(process.env.PRIVATE_KEY, provider);

const abi = [
  "function isRegistered(address user) view returns (bool)",
  "function register(address user, bytes32 profileId, string metadataURI) external",
  "function getProfile(address user) view returns (bytes32 profileId, string metadataURI, uint8 status, uint256 createdAt)"
];

const contract = new ethers.Contract(process.env.CONTRACT_ADDRESS, abi, signer);

// 简化演示:用内存存 nonce。生产环境请换 Redis/DB。
const nonces = new Map();

function buildMessage(address, nonce) {
  return [
    "Login to Demo DApp",
    `Address: ${address}`,
    `Nonce: ${nonce}`,
    `ChainId: ${CHAIN_ID}`,
    `IssuedAt: ${new Date().toISOString()}`
  ].join("\n");
}

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

    const normalized = ethers.getAddress(address);
    const nonce = crypto.randomBytes(16).toString("hex");
    const message = buildMessage(normalized, nonce);

    nonces.set(normalized, {
      nonce,
      message,
      createdAt: Date.now(),
      used: false
    });

    res.json({ address: normalized, nonce, message, chainId: CHAIN_ID });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.post("/auth/verify", async (req, res) => {
  try {
    const { address, message, signature } = req.body;
    if (!address || !message || !signature) {
      return res.status(400).json({ error: "missing params" });
    }

    const normalized = ethers.getAddress(address);
    const record = nonces.get(normalized);

    if (!record) {
      return res.status(400).json({ error: "nonce not found" });
    }

    if (record.used) {
      return res.status(400).json({ error: "nonce already used" });
    }

    if (Date.now() - record.createdAt > 5 * 60 * 1000) {
      return res.status(400).json({ error: "nonce expired" });
    }

    if (record.message !== message) {
      return res.status(400).json({ error: "message mismatch" });
    }

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

    record.used = true;

    let registered = await contract.isRegistered(normalized);
    let profileId;

    if (!registered) {
      const profileIdHex = ethers.keccak256(
        ethers.toUtf8Bytes(`${normalized}:${Date.now()}`)
      );
      profileId = profileIdHex;
      const metadataURI = `ipfs://demo-profile/${normalized.toLowerCase()}`;

      const tx = await contract.register(normalized, profileIdHex, metadataURI);
      await tx.wait();
    }

    const profile = await contract.getProfile(normalized);
    profileId = profile[0];

    const token = jwt.sign(
      {
        sub: normalized,
        wallet: normalized,
        profileId,
        chainId: CHAIN_ID
      },
      JWT_SECRET,
      { expiresIn: "2h" }
    );

    res.json({
      token,
      user: {
        address: normalized,
        profileId,
        metadataURI: profile[1],
        status: Number(profile[2])
      }
    });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: err.message });
  }
});

app.get("/me", async (req, res) => {
  try {
    const auth = req.headers.authorization || "";
    const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
    if (!token) {
      return res.status(401).json({ error: "missing token" });
    }

    const payload = jwt.verify(token, JWT_SECRET);
    const profile = await contract.getProfile(payload.wallet);

    res.json({
      wallet: payload.wallet,
      profileId: profile[0],
      metadataURI: profile[1],
      status: Number(profile[2]),
      chainId: payload.chainId
    });
  } catch (err) {
    res.status(401).json({ error: err.message });
  }
});

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

二、前端:钱包连接与登录

新建一个 index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Web3 Wallet Login Demo</title>
</head>
<body>
  <h2>Web3 钱包登录 Demo</h2>
  <button id="connectBtn">连接钱包</button>
  <button id="loginBtn">签名登录</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 provider;
    let signer;
    let address;

    function log(data) {
      output.textContent = typeof data === "string" ? data : JSON.stringify(data, null, 2);
    }

    document.getElementById("connectBtn").onclick = async () => {
      if (!window.ethereum) {
        log("请安装 MetaMask");
        return;
      }
      provider = new ethers.BrowserProvider(window.ethereum);
      await provider.send("eth_requestAccounts", []);
      signer = await provider.getSigner();
      address = await signer.getAddress();
      log({ connected: true, address });
    };

    document.getElementById("loginBtn").onclick = async () => {
      try {
        if (!signer || !address) {
          log("请先连接钱包");
          return;
        }

        const nonceResp = await fetch(`http://localhost:3000/auth/nonce?address=${address}`);
        const nonceData = await nonceResp.json();
        if (!nonceResp.ok) {
          log(nonceData);
          return;
        }

        const signature = await signer.signMessage(nonceData.message);

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

        const verifyData = await verifyResp.json();
        if (!verifyResp.ok) {
          log(verifyData);
          return;
        }

        localStorage.setItem("token", verifyData.token);
        log(verifyData);
      } catch (err) {
        log({ error: err.message });
      }
    };
  </script>
</body>
</html>

三、部署与运行

1. 部署合约

你可以用 Hardhat 部署。这里给最小脚本。

安装:

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat

contracts/IdentityRegistry.sol 放上面的合约代码。

创建 scripts/deploy.js

const hre = require("hardhat");

async function main() {
  const Factory = await hre.ethers.getContractFactory("IdentityRegistry");
  const contract = await Factory.deploy();
  await contract.waitForDeployment();
  console.log("IdentityRegistry deployed to:", await contract.getAddress());
}

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

执行:

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

2. 启动后端

node server.js

3. 打开前端页面

直接用浏览器打开 index.html,或者起个静态服务器:

npx serve .

容量估算与架构扩展

如果只是 Demo,到这里就够了。
但如果你打算给真实项目用,建议提前考虑规模问题。

登录请求量

假设:

  • 日活 5 万
  • 每人每天平均登录 1.5 次
  • 峰值在 10 分钟内集中 20%

则高峰期登录请求大约是:

50000 * 1.5 * 20% / 600 ≈ 25 次/秒

这对普通 Node 服务完全没压力。

链上注册请求量

不是每次登录都上链,只有首次注册身份变更才上链。
如果新用户转化每天 2000,平均分布也只是很低的 TPS。

真正要担心的不是 TPS,而是:

  • RPC 可靠性
  • 交易确认延迟
  • 重试导致的重复提交

推荐扩展策略

小规模

  • 单实例认证服务
  • Redis 存 nonce
  • PostgreSQL 存用户快照
  • 单个链上 registry 合约

中规模

  • 认证服务无状态化,多实例部署
  • Redis 做 nonce + 幂等锁
  • MQ 异步处理首次链上注册
  • 后端先发临时 token,再等注册完成升级正式身份

大规模

  • 多链支持
  • 索引服务同步身份状态
  • 事件驱动同步 DB 快照
  • 链上操作交给任务队列与交易中继层

常见坑与排查

这部分非常重要。我自己做这类系统时,80% 的时间都花在“看起来没问题,但就是登录失败”的细节上。

1. signature invalid

常见原因

  • 前端签名的 message 和后端保存的 message 不完全一致
  • 地址大小写没统一
  • 使用了 signTypedData,后端却用 verifyMessage
  • 用户切换了钱包账户

排查方法

打印以下内容逐项比对:

console.log({
  address,
  message,
  signature
});

后端也打印:

console.log({
  recovered,
  expected: normalized,
  savedMessage: record.message,
  incomingMessage: message
});

经验建议:
不要自己在前端“重新拼 message”,直接签后端返回的原始字符串。


2. nonce 明明刚拿到却提示过期

常见原因

  • 前端拿到 nonce 后停留太久
  • 用户钱包弹窗没点,拖了几分钟
  • 服务端 nonce TTL 过短

建议

  • nonce 有效期 3~5 分钟比较合理
  • 过期后直接重新请求 nonce
  • 一个地址只保留最新 nonce,旧 nonce 自动失效

3. 合约注册重复执行

现象

用户连续点击登录按钮,或者前端重试,导致后端多次发起 register

解决思路

  • 后端先查 isRegistered
  • 用数据库/Redis 对 address 加幂等锁
  • 捕获合约 already registered 错误并视为成功分支

我实际项目里一般会这么处理:

try {
  const tx = await contract.register(user, profileId, metadataURI);
  await tx.wait();
} catch (e) {
  const registered = await contract.isRegistered(user);
  if (!registered) throw e;
}

4. 链切换导致认证错链

现象

用户钱包当前连的是 Polygon,但你后端按 Sepolia 的 chainId 在验。

建议

  • 签名消息里必须包含 ChainId
  • 前端连接后先主动检查链
  • 错链就提示用户切换,不要糊里糊涂继续

5. 智能合约钱包无法正常登录

比如 Safe 多签、合约钱包等场景,普通 EOA 签名验证不总是适用。

原因

ethers.verifyMessage 适用于 EOA,
而合约钱包可能需要走 ERC-1271 验证。

排查方向

  • 判断地址是否为合约账户
  • 如果是合约钱包,调用 isValidSignature

这一步是很多项目后期才补的。如果你服务的是高级用户群体,最好一开始就纳入设计。


安全最佳实践

这一节我建议你认真看,因为 Web3 登录最容易“功能能跑,但安全边界不清”。

1. nonce 必须一次性、短时效

要求:

  • 随机性足够
  • 绑定地址
  • 用后即焚
  • 过期失效

不要接受固定文案签名,例如:

I am signing in

这种写法几乎就是在给重放攻击开门。


2. 签名内容必须有域隔离

建议签名消息至少包含:

  • 应用名
  • 钱包地址
  • nonce
  • chainId
  • issuedAt
  • 可选 domain / origin

例如:

Login to Demo DApp
Address: 0x...
Nonce: ...
ChainId: 11155111
IssuedAt: ...

更进一步,推荐使用 SIWE(Sign-In with Ethereum, EIP-4361) 标准格式。


3. 服务端不要把“验签成功”直接等同于“有业务权限”

验签只能说明:

这个人控制了这个地址。

它不能说明:

  • 他是否完成 KYC
  • 是否被封禁
  • 是否具备某角色
  • 是否仍然持有某 NFT / SBT

所以业务授权应该至少看:

  • session
  • 链上身份状态
  • 业务数据库状态

4. 合约写操作最好异步化

首次登录就上链注册,用户体验通常会卡在交易确认。
更稳妥的方式是:

  • 登录成功后先发临时 session
  • 链上注册放后台队列
  • 完成后更新身份状态

当然,这取决于你的业务是否要求“登录后立刻有链上身份”。


5. 私钥管理不能草率

后端服务钱包通常掌握身份注册权限,这个风险很高。

建议:

  • 使用专用热钱包,不要复用运营钱包
  • 设置每日额度和告警
  • 迁移到 KMS / HSM / MPC 托管
  • 合约 owner 最好交给多签,而不是单私钥

6. JWT 不要无限期有效

建议:

  • access token:30 分钟到 2 小时
  • refresh token:单独管理,支持吊销
  • 高风险操作重新校验链上状态或重新签名

性能最佳实践

钱包登录本身不算重,但和链上交互一旦混在主链路里,性能问题就会暴露。

1. 链上读取做缓存

getProfile 这种读操作虽然是 view,但频繁打 RPC 仍然会慢。

可以做:

  • 本地缓存 30~120 秒
  • 事件驱动更新缓存
  • 用户登录时拉一份快照存数据库

2. nonce 存 Redis,不要只放内存

文章里的 demo 用 Map 只是为了简洁。
正式环境多实例部署时,如果 nonce 存内存,会出现:

  • 请求打到 A 实例生成 nonce
  • 验证打到 B 实例找不到 nonce

所以必须上 Redis 或共享存储。

3. 合约注册做幂等与队列

如果链拥堵或 RPC 抖动,直接同步等待交易确认会拉长登录时间。
可以改成:

  • API 返回 pending
  • 异步 worker 提交交易
  • 前端轮询注册状态

4. 区分“登录态”和“身份态”

这是我比较推荐的设计:

  • 登录态:只表示用户已完成签名认证
  • 身份态:表示链上 profile 是否已激活

这样你就能把“能进系统”和“能用某功能”分开处理,弹性会大很多。


进一步演进方向

如果你要把这套系统做成更成熟的身份层,可以往这些方向继续:

1. 接入 EIP-4361(SIWE)

好处是:

  • 消息格式标准化
  • 更容易和生态工具兼容
  • 降低自定义消息造成的解析错误

2. 支持 ERC-1271

兼容合约钱包,是走向机构用户和高阶 Web3 用户的关键一步。

3. 引入 ENS / Lens / Farcaster 等外部身份信号

让“钱包地址”不仅是地址,还能关联社交身份、命名系统、声誉体系。

4. 用 SBT / Attestation 承载更复杂身份

例如:

  • KYC 已完成
  • 某课程已认证
  • 某 DAO 成员资格
  • 企业员工凭证

这比单纯 address -> profileId 更适合做链上身份组合。


总结

如果你只需要“用户能登录”,那一套最简单的 nonce + 签名 + JWT 就够了。
但如果你要搭的是一个真正可持续演进的 Web3 身份系统,我建议按下面这个思路来:

  1. 钱包签名负责证明地址控制权
  2. 智能合约负责登记公开可验证的身份关系
  3. 后端 session 负责承载高频业务访问
  4. 业务授权不要只看验签结果,还要看身份状态
  5. 首次上链注册要做好幂等、异步和错误兜底

一句话概括这套架构的边界:

链上负责可信身份锚点,链下负责高性能认证体验。

如果你现在准备落地,我的可执行建议是:

  • 先做一个最小版:EOA 钱包 + nonce 签名 + registry 合约 + JWT
  • 第二阶段补:Redis、幂等锁、事件同步、链上状态缓存
  • 第三阶段再补:SIWE、ERC-1271、多链、角色系统

这样推进最稳,不会一开始就把系统做得太重,也不会后面推倒重来。


分享到:

上一篇
《大模型应用落地指南:从 RAG 知识库搭建到检索效果优化实战》
下一篇
《安卓逆向实战:基于 Frida 与 JADX 的加固 App 登录参数生成链路分析与 Hook 绕过》