Web3 全栈开发实战:从钱包集成到签名验证

如今,Web3 应用早已不只是“连接个钱包”那么简单。一个真正可用的 DApp,至少需要做到三件事:前端流畅地连接钱包、与智能合约安全交互、后端可靠地验证用户身份。本文将以 React + wagmi + viem 为前端核心,Node.js 为后端,带你一步步搭建一个包含完整钱包集成流程的全栈 DApp。

读完这篇文章,你将能够:

  • 在 React 应用中集成多钱包支持
  • 读取链上数据并发送交易
  • 实现“连接钱包即登录”
  • 在后端验证钱包签名以确保用户身份

全文代码均可直接运行,你也可以在这个基础上自由扩展。


1. 环境准备

开始之前,请确保本机已安装:

  • Node.js ≥ 18
  • pnpm(或 npm / yarn)
  • MetaMask 或其他钱包浏览器插件

我们将在同一个仓库中维护前端和后端代码,最终目录结构如下:

web3-fullstack-demo/
├── frontend/   # React + Vite
└── backend/    # Express

2. 前端:项目初始化 & 钱包连接

2.1 创建项目

使用 Vite 快速搭建 React + TypeScript 工程:

pnpm create vite frontend --template react-ts
cd frontend
pnpm install

2.2 安装 Web3 依赖

我们使用 wagmi 管理钱包连接,viem 负责底层 RPC 调用与合约交互:

pnpm add wagmi viem @tanstack/react-query

wagmi v2 基于 vanilla 设计,与 viem 深度集成,是目前 React 生态中最主流的钱包连接方案。

2.3 配置 wagmi 与链

创建 frontend/src/config/web3.ts

import { http, createConfig } from 'wagmi'
import { mainnet, sepolia } from 'wagmi/chains'
import { injected, metaMask, walletConnect } from 'wagmi/connectors'

export const config = createConfig({
  chains: [mainnet, sepolia],
  connectors: [
    injected(),           // 浏览器注入钱包(如 MetaMask)
    metaMask(),           // 显式指定 MetaMask
    walletConnect({       // WalletConnect 扫码连接
      projectId: '你的_WalletConnect_Project_ID', // 从 cloud.walletconnect.com 获取
    }),
  ],
  transports: {
    [mainnet.id]: http(),
    [sepolia.id]: http(),
  },
})

2.4 提供全局 Context

修改 frontend/src/main.tsx,包裹 WagmiProvider:

import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { WagmiProvider } from 'wagmi'
import { config } from './config/web3'
import App from './App'

const queryClient = new QueryClient()

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>
)

2.5 实现“连接钱包”按钮

App.tsx 中,我们用 wagmi 的 Hook 快速实现连接与账户信息展示:

import { useAccount, useConnect, useDisconnect, useBalance } from 'wagmi'

function App() {
  const { address, isConnected } = useAccount()
  const { connect, connectors, error, status } = useConnect()
  const { disconnect } = useDisconnect()
  const { data: balance } = useBalance({ address })

  return (
    <div style={{ padding: 32 }}>
      <h1> Web3 钱包集成 Demo</h1>

      {isConnected ? (
        <div>
          <p> 已连接:{address}</p>
          <p> 余额:{balance?.formatted} {balance?.symbol}</p>
          <button onClick={() => disconnect()}>断开连接</button>
        </div>
      ) : (
        <div>
          <p>请先连接钱包</p>
          {connectors.map((connector) => (
            <button
              key={connector.id}
              onClick={() => connect({ connector })}
              disabled={status === 'pending'}
            >
              {connector.name}
              {!connector.ready && ' (未安装)'}
            </button>
          ))}
          {error && <p style={{ color: 'red' }}>{error.message}</p>}
        </div>
      )}
    </div>
  )
}

export default App

运行 pnpm dev,你应该能看到连接按钮,并能成功连接 MetaMask 或 WalletConnect。

到此,钱包连接的基础设施就搭建完成了。


3. 前端:与合约交互

3.1 准备合约 ABI 与地址

假设我们已部署一个简单的“存取值”合约在 Sepolia 测试网:

// ValueStore.sol
contract ValueStore {
    uint256 public value;

    event ValueChanged(uint256 newValue);

    function setValue(uint256 _value) external {
        value = _value;
        emit ValueChanged(_value);
    }
}

合约地址:0xYourContractAddress
ABI 只需保留会用到的片段(也可以直接导入完整 JSON)。

创建 frontend/src/contracts/valueStore.ts

export const VALUE_STORE_ADDRESS = '0xYourContractAddress'

export const VALUE_STORE_ABI = [
  {
    inputs: [],
    name: 'value',
    outputs: [{ type: 'uint256' }],
    stateMutability: 'view',
    type: 'function',
  },
  {
    inputs: [{ name: '_value', type: 'uint256' }],
    name: 'setValue',
    outputs: [],
    stateMutability: 'nonpayable',
    type: 'function',
  },
  {
    anonymous: false,
    inputs: [{ indexed: false, name: 'newValue', type: 'uint256' }],
    name: 'ValueChanged',
    type: 'event',
  },
] as const

3.2 读取合约数据

使用 useReadContract 读取链上状态:

import { useReadContract } from 'wagmi'
import { VALUE_STORE_ADDRESS, VALUE_STORE_ABI } from './contracts/valueStore'

function ContractData() {
  const { data: value, isError, isLoading } = useReadContract({
    address: VALUE_STORE_ADDRESS,
    abi: VALUE_STORE_ABI,
    functionName: 'value',
  })

  if (isLoading) return <p>加载中…</p>
  if (isError) return <p>读取失败</p>
  return <p> 合约当前值:{value?.toString()}</p>
}

3.3 写入合约(发送交易)

useWriteContract + useWaitForTransactionReceipt 实现写操作并等待上链:

import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { VALUE_STORE_ADDRESS, VALUE_STORE_ABI } from './contracts/valueStore'
import { parseEther } from 'viem'
import { useState } from 'react'

function SetValue() {
  const [inputValue, setInputValue] = useState('')
  const { data: hash, writeContract } = useWriteContract()

  const { isLoading: isConfirming, isSuccess: isConfirmed } =
    useWaitForTransactionReceipt({ hash })

  const handleSet = () => {
    if (!inputValue) return
    writeContract({
      address: VALUE_STORE_ADDRESS,
      abi: VALUE_STORE_ABI,
      functionName: 'setValue',
      args: [BigInt(inputValue)],
    })
  }

  return (
    <div>
      <input
        type="number"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="新值"
      />
      <button onClick={handleSet} disabled={isConfirming}>
        {isConfirming ? '确认中…' : '设置'}
      </button>
      {hash && <p>交易哈希:{hash}</p>}
      {isConfirmed && <p style={{ color: 'green' }}> 上链成功!</p>}
    </div>
  )
}

这样我们就完成了钱包连接、余额查询、合约读写等前端核心功能。


4. 连接钱包即登录:签名验证流程

很多 DApp 需要后端确认“某个操作确实来自该钱包地址”。标准做法是:

  1. 后端生成一条随机消息(nonce)
  2. 前端用钱包对该消息签名
  3. 后端验证签名,返回 JWT 或 session

这比传统的密码登录更安全、更去中心化。

4.1 后端:生成 Nonce 接口

创建 backend/ 目录并初始化:

mkdir backend && cd backend
pnpm init
pnpm add express cors viem
pnpm add -D typescript @types/express @types/cors tsx

配置 tsconfig.json,然后编写 backend/src/index.ts

import express from 'express'
import cors from 'cors'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'

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

// 用于签名验证的客户端(也可以不连链,直接用 viem 的 verifyMessage)
// 这里使用 viem 原生方法,不需要 provider

// 存储 nonce(实际项目应存数据库,并绑定 session)
const nonces = new Map<string, string>()

// 1. 请求 nonce
app.post('/auth/nonce', (req, res) => {
  const { address } = req.body
  if (!address) return res.status(400).json({ error: '缺少地址' })

  const nonce = `登录验证:${Math.floor(Math.random() * 1e9)}`
  nonces.set(address, nonce)

  // 设置过期时间(示例 5 分钟)
  setTimeout(() => nonces.delete(address), 5 * 60 * 1000)

  res.json({ nonce })
})

// 2. 验证签名
app.post('/auth/verify', (req, res) => {
  const { address, signature } = req.body
  const nonce = nonces.get(address)

  if (!nonce) return res.status(400).json({ error: 'nonce 无效或已过期' })

  try {
    // viem 的 verifyMessage 可以直接验证以太坊个人签名
    const isValid = verifyMessage({
      address: address as `0x${string}`,
      message: nonce,
      signature: signature as `0x${string}`,
    })

    if (isValid) {
      nonces.delete(address)
      // 这里可以签发 JWT,示例中直接返回成功
      res.json({ success: true, token: 'your-jwt-token' })
    } else {
      res.status(401).json({ error: '签名验证失败' })
    }
  } catch (e) {
    res.status(401).json({ error: '签名验证异常' })
  }
})

app.listen(3001, () => {
  console.log('后端运行在 http://localhost:3001')
})

注意:上面的 verifyMessage 需要从 viem 导入,实际代码顶部加上:
import { verifyMessage } from 'viem'

4.2 前端:发起登录流程

在前端封装登录逻辑,点击“使用钱包登录”时调用:

import { useSignMessage } from 'wagmi'

function LoginWithWallet() {
  const { address } = useAccount()
  const { signMessageAsync } = useSignMessage()

  const login = async () => {
    if (!address) return

    // 1. 获取 nonce
    const nonceRes = await fetch('http://localhost:3001/auth/nonce', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address }),
    })
    const { nonce } = await nonceRes.json()

    // 2. 钱包签名
    const signature = await signMessageAsync({ message: nonce })

    // 3. 发送签名到后端验证
    const verifyRes = await fetch('http://localhost:3001/auth/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address, signature }),
    })
    const result = await verifyRes.json()

    if (result.success) {
      alert('登录成功!')
      // 保存 token,后续请求携带
    } else {
      alert('登录失败')
    }
  }

  return <button onClick={login}> 钱包登录</button>
}

至此,“连接钱包即登录”的完整流程就跑通了。


5. 常见问题与优化建议

5.1 切换网络

wagmi 提供了 useSwitchChain Hook,可以引导用户切换到指定链,或者使用 switchChain 直接调用。

5.2 多钱包冲突

当用户同时安装 MetaMask 和 Coinbase Wallet 时,injected 连接器会出现多个选项。使用 metaMask()coinbaseWallet() 显式指定可以避免混淆。

5.3 生产环境安全

  • Nonce 应存储在 Redis/数据库中,并设置合理过期时间。
  • 避免在 Nonce 中泄露敏感信息。
  • 签名验证通过后务必签发具备时效性的 JWT,后续接口验证 JWT 而非重复签名。
  • 前端请求后端时,使用 HTTPS 防止中间人攻击。

6. 总结

这篇文章我们从零搭建了一个 Web3 全栈应用的钱包集成方案,覆盖了:

  • React 中使用 wagmi + viem 连接多种钱包
  • 读取链上余额与合约状态
  • 发送交易并等待上链确认
  • 实现“签名即登录”的用户认证流程

完整代码已经具备了可扩展性:你可以在前端接入更多的合约方法,在后端加入数据库持久化用户数据,或者用 JWT 保护 API 接口。希望这篇教程能帮你迈出 Web3 全栈开发坚实的一步。


如果这篇教程对你有帮助,欢迎分享或留言交流。我们下一篇文章再见!