Web3 全栈开发实战:多链支持与智能钱包集成

前面四篇文章,我们的全栈应用始终锚定在 Sepolia 这一条测试网上。但在现实世界中,一个成熟的 DApp 需要同时服务 Ethereum、Polygon、Arbitrum、Optimism 甚至更多链的用户。与此同时,越来越多的项目和个人使用智能合约钱包(如 Safe 多签钱包)来管理资产,它和普通 EOA 钱包的交互方式略有不同。

今天这篇文章,你将完成:

  • 让前端动态切换多条 EVM 链
  • 统一管理各链上的合约读写与事件监听
  • 集成 Safe 智能钱包作为登录和签名方案
  • 后端适配多链事件索引
  • 构建一条“多链代币余额总览”面板

所有代码延续 React + wagmi + viem + Node.js。


1. 多链架构设计

我们不必为每条链都写一套代码。wagmi 本身就支持多链配置,只需在 config 里添加更多链,然后在组件中动态获取当前 chainId,就可以决定使用哪个合约地址和 RPC。

1.1 配置更多链

打开 frontend/src/config/web3.ts,增加几条常用的 EVM 链:

import { http, createConfig } from 'wagmi'
import {
  mainnet, sepolia,
  polygon, polygonMumbai,
  arbitrum, arbitrumSepolia,
  optimism, optimismSepolia,
} from 'wagmi/chains'
import { injected, metaMask, walletConnect } from 'wagmi/connectors'

export const config = createConfig({
  chains: [
    mainnet, sepolia,
    polygon, polygonMumbai,
    arbitrum, arbitrumSepolia,
    optimism, optimismSepolia,
  ],
  connectors: [
    injected(),
    metaMask(),
    walletConnect({
      projectId: '你的_WalletConnect_Project_ID',
    }),
  ],
  transports: {
    [mainnet.id]: http(),
    [sepolia.id]: http(),
    [polygon.id]: http(),
    [polygonMumbai.id]: http(),
    [arbitrum.id]: http(),
    [arbitrumSepolia.id]: http(),
    [optimism.id]: http(),
    [optimismSepolia.id]: http(),
  },
})

1.2 根据链动态选择地址

之前我们的合约地址是硬编码的。现在可以创建一个映射表:

// contracts/addresses.ts
export const TOKEN_ADDRESSES: Record<number, string> = {
  1: '0x...',        // mainnet MTK
  11155111: '0xA1B2C3...', // sepolia MTK
  137: '0x...',
  80001: '0x...',    // mumbai
  42161: '0x...',
  421614: '0x...',
  10: '0x...',
  11155420: '0x...',
}

export const WETH_ADDRESSES: Record<number, string> = {
  1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
  11155111: '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14',
  // ...其他链的 WETH
}

export const UNISWAP_V2_ROUTER_ADDRESSES: Record<number, string> = {
  1: '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D',
  11155111: '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D',
  // ...
}

在组件中使用 useAccountchainId 即可动态取到正确的地址:

const { chainId } = useAccount()
const tokenAddress = TOKEN_ADDRESSES[chainId ?? 11155111]

1.3 切换链与添加链

wagmi 提供了 useSwitchChainuseAddChain(实验性)。我们可以快速构造一个网络选择器:

import { useSwitchChain, useChainId } from 'wagmi'

function ChainSwitcher() {
  const chainId = useChainId()
  const { switchChain, isPending } = useSwitchChain()

  const supportedChains = [
    { id: 1, name: 'Ethereum' },
    { id: 11155111, name: 'Sepolia' },
    { id: 137, name: 'Polygon' },
    { id: 42161, name: 'Arbitrum' },
    { id: 10, name: 'Optimism' },
  ]

  return (
    <div>
      <h4>当前网络:{supportedChains.find(c => c.id === chainId)?.name}</h4>
      {supportedChains.map((c) => (
        <button key={c.id} onClick={() => switchChain({ chainId: c.id })} disabled={isPending}>
          {c.name}
        </button>
      ))}
    </div>
  )
}

这样一来,你的 DApp 就能自由穿梭在各大 EVM 生态之间。


2. 多链余额面板

读取一个地址在多个链上的代币余额,可以组合多个 useReadContract,但这样会产生大量请求。更好的做法是封装一个 Hook 统一调用。

2.1 创建 useMultichainBalances Hook

import { useReadContracts, useAccount } from 'wagmi'
import { erc20Abi } from 'viem'

const CHAINS_TO_CHECK = [1, 11155111, 137, 42161, 10] as const

export function useMultichainBalances(tokenAddressMap: Record<number, string>) {
  const { address } = useAccount()

  const contracts = CHAINS_TO_CHECK.map((chainId) => ({
    chainId,
    address: tokenAddressMap[chainId] as `0x${string}`,
    abi: erc20Abi,
    functionName: 'balanceOf' as const,
    args: address ? [address] : undefined,
  }))

  const { data, isLoading } = useReadContracts({
    contracts,
    query: { enabled: !!address },
  })

  // 将结果与链名称映射
  const result = CHAINS_TO_CHECK.map((chainId, index) => ({
    chainId,
    chainName: getChainName(chainId),
    balance: data?.[index]?.result,
    isLoading,
  }))

  return result
}

然后在组件中展示:

function BalancePanel() {
  const balances = useMultichainBalances(TOKEN_ADDRESSES)

  return (
    <div>
      <h3>📊 多链 MTK 余额</h3>
      <ul>
        {balances.map((b) => (
          <li key={b.chainId}>
            {b.chainName}: {b.balance ? formatUnits(b.balance, 18) : '—'} MTK
          </li>
        ))}
      </ul>
    </div>
  )
}

3. 集成 Safe 智能钱包

Safe(原 Gnosis Safe)是最流行的智能合约钱包,支持多签和账户抽象。它对外暴露的接口与普通 EOA 类似,但交易需要通过 execTransaction 或 Safe SDK 来进行,签名验证逻辑也不同。

在 DApp 层面,直接使用 Safe{Core} SDK 可以无缝集成。我们也可以利用 wagmi 的 connectorssafe 连接器(需要额外安装),或直接手动适配。

3.1 使用 Safe 连接器

安装依赖:

pnpm add @safe-global/safe-apps-sdk @wagmi/connectors

web3.ts 中添加 Safe 连接器:

import { safe } from 'wagmi/connectors'

export const config = createConfig({
  // ...
  connectors: [
    injected(),
    metaMask(),
    walletConnect({ projectId: '...' }),
    safe(),
  ],
  // ...
})

这样,在 Safe 内嵌浏览器或 Safe 移动 App 内打开 DApp 时,会自动使用 Safe 连接器。但对于桌面版普通浏览器,Safe 连接器不会直接弹出钱包,需要用户手动复制 Safe 地址后粘贴进行模拟连接(通常用于开发者测试)。

3.2 识别是否为智能钱包

我们可以通过获取地址的 code 来判断是 EOA 还是合约:

import { useBytecode } from 'wagmi'
import { useAccount } from 'wagmi'

function useIsSmartWallet() {
  const { address } = useAccount()
  const { data: bytecode } = useBytecode({ address })
  return bytecode && bytecode !== '0x'
}

智能钱包的签名方式可能不同。Safe 使用 EIP-712 签名或合约签名,wagmi 的 signMessage 会自动适配(Safe 支持标准 eth_sign)。但如果遇到不支持的,则需要使用 Safe SDK 的 signMessage 方法。

3.3 发送交易时 Gas 估计

智能钱包的交易消耗 Gas 更多,且 Gas 预估依赖于内部逻辑。wagmi 和 viem 会自动处理大部分情况,但有时需要手动设置 gasLimit。例如 Safe 的 execTransaction 有时需要额外缓冲:

const { writeContract } = useWriteContract()

// 在 mutate 时传入 gas 缓冲
writeContract({
  address: tokenAddress,
  abi: erc20Abi,
  functionName: 'transfer',
  args: [to, amount],
  gas: 300000n, // Safe 交易一般需要更高的 Gas
})

4. 多签流程与自定义 Hooks

Safe 的核心流程:一个用户发起交易,其他拥有者确认(approve),达到阈值后任意一人执行。

如果要在 DApp 内构建多签管理界面,可以使用 @safe-global/api-kit@safe-global/protocol-kit。这里给一个最小示例。

4.1 安装

pnpm add @safe-global/protocol-kit @safe-global/api-kit

4.2 创建 Safe 实例并发起交易

import SafeApiKit from '@safe-global/api-kit'
import Safe from '@safe-global/protocol-kit'
import { ethers } from 'ethers' // v5 兼容,或 viem 适配器

const SAFE_ADDRESS = '0xYourSafeAddress'

async function proposeTransaction() {
  const provider = new ethers.BrowserProvider(window.ethereum)
  const signer = await provider.getSigner()

  const safeSdk = await Safe.init({
    provider: window.ethereum,
    signer: await signer.getAddress(),
    safeAddress: SAFE_ADDRESS,
  })

  const safeTransaction = await safeSdk.createTransaction({
    transactions: [{
      to: TOKEN_ADDRESS,
      value: '0',
      data: encodeTransferData(to, amount),
    }],
  })

  const safeTxHash = await safeSdk.getTransactionHash(safeTransaction)
  const signature = await safeSdk.signHash(safeTxHash)

  // 使用 API Kit 提交到 Safe 服务
  const apiKit = new SafeApiKit({ chainId: 11155111n })
  await apiKit.proposeTransaction({
    safeAddress: SAFE_ADDRESS,
    safeTransactionData: safeTransaction.data,
    safeTxHash,
    senderAddress: await signer.getAddress(),
    senderSignature: signature.data,
  })
}

在你的 DApp 中,可以封装一个“多签建议”页面,列出待确认交易并允许签名。


5. 后端:多链事件索引

后端也可以用 viem 监听多条链,只需创建多个 PublicClient 并分别启动监听器。

5.1 动态创建客户端

// backend/src/chains.ts
import { createPublicClient, http } from 'viem'
import { mainnet, sepolia, polygon } from 'viem/chains'

export const chainClients = {
  [mainnet.id]: createPublicClient({ chain: mainnet, transport: http() }),
  [sepolia.id]: createPublicClient({ chain: sepolia, transport: http() }),
  [polygon.id]: createPublicClient({ chain: polygon, transport: http() }),
}

5.2 启动多链 Swap 监听

export function startAllSwapListeners(pairMap: Record<number, string>) {
  for (const [chainId, pairAddress] of Object.entries(pairMap)) {
    const client = chainClients[Number(chainId)]
    if (!client || !pairAddress) continue

    client.watchContractEvent({
      address: pairAddress as `0x${string}`,
      abi: pairAbi,
      eventName: 'Swap',
      onLogs(logs) {
        for (const log of logs) {
          insertSwapEvent({ chainId: Number(chainId), ...log })
        }
      },
    })
  }
}

记得在 swap_events 表中增加 chainId 字段,API 也支持按链过滤。


6. 前端集成多链历史

修改 TransferHistory 组件,增加链切换筛选。也可以直接合并展示:

function AllChainSwapHistory() {
  const [chainFilter, setChainFilter] = useState<number | 'all'>('all')
  const [events, setEvents] = useState([])

  useEffect(() => {
    fetch(`http://localhost:3001/swaps?chainId=${chainFilter}`)
      .then(res => res.json())
      .then(setEvents)
  }, [chainFilter])

  // 渲染...
}

7. 安全与 UX 考量

  • 网络切换前提示:自动切换可能被浏览器拦截,应在 UI 提供手动切换按钮。
  • 智能钱包 Gas 补贴:考虑使用账户抽象基础设施(如 Biconomy、Pimlico)来赞助 Gas。
  • 多签提示:当检测到用户使用多签钱包时,应显示“待确认”状态,避免误以为交易已立即完成。
  • RPC 稳定性:生产环境建议使用自定义 RPC URL(如 Infura、Alchemy),而非公共端点。

8. 总结

这篇为你打开了“多链世界”的大门:

  • 动态多链配置与网络切换
  • 基于链 ID 的合约地址映射
  • 多链余额聚合展示
  • 集成 Safe 智能钱包与多签流程
  • 后端多链事件监听