Next.js DApp 中间件层:钱包认证、路由守卫与 RPC 故障转移

发布时间:2026/7/6 23:44:17
Next.js DApp 中间件层:钱包认证、路由守卫与 RPC 故障转移 Next.js DApp 中间件层钱包认证、路由守卫与 RPC 故障转移一、前端直接裸连链上基础设施是一种不可持续的模式一个典型的 Next.js DApp 启动流程通常是安装 wagmi/ethers、配个 RPC URL、写个useConnect然后就开始写业务。这种模式在 Demo 阶段能跑但到了生产环境就会暴露问题。首先是钱包认证的可靠性。用户可能在页面生命周期的任何阶段断开钱包、切换链、切换账户。如果每个页面各自处理这些事件状态不一致是必然结果。其次是 RPC 节点的可用性。Infura/Alchemy 的免费端点有速率限制和间歇故障单一 RPC 端点没有任何容错能力。第三是路由安全问题。某些页面需要钱包连接后才能访问但客户端路由守卫很容易被绕过——因为 Next.js 的页面组件在客户端渲染前中间件层不能直接访问浏览器钱包状态。所以在页面组件之前需要一个中间件层统一处理钱包连接状态、网络切换、RPC 故障转移和路由守卫。这不是可选的架构优化而是 DApp 从 Demo 到生产的必要条件。二、中间件层架构把横切关注点从页面组件中剥离flowchart TD A[用户访问] -- B{钱包已连接?} B --|否| C[中间件拦截 → 重定向连接页] B --|是| D{所在链正确?} D --|否| E[中间件触发 switchChain] D --|是| F{RPC 健康检查} F --|失败| G[故障转移: 备用 RPC] F --|通过| H[注入 WalletContext RPC Provider] G -- H H -- I[页面组件渲染] E -- F C -- J[连接页面] J -- B设计原则是关注点分离页面组件只关心自己的业务逻辑渲染数据、处理用户交互不关心钱包是 MetaMask 还是 WalletConnect、RPC 是 Infura 还是自建节点、当前在以太坊还是 Polygon。这些横切关注点由中间件统一管理通过 React Context 向下传递。三、生产级实现三层中间件第一层RPC 故障转移与健康检查RPC 是 DApp 的命脉——它断了所有链上读操作全部挂掉。以下实现不依赖任何特定库只基于 ethers.js 的 JsonRpcProvider// lib/rpc/RpcFailover.ts import { ethers } from ethers; /** * RPC 节点池配置 * * 设计决策 * - 主节点一个、备用节点两个二热备避免所有请求打到一个节点触发限流 * - 每 30 秒做一次健康检查eth_chainId 比 eth_blockNumber 更轻量 * - 健康检查超时 3 秒生产环境 3 秒还拿不到 chainId这个节点基本不可用 */ interface RpcNode { url: string; weight: number; healthy: boolean; lastCheck: number; } const HEALTH_CHECK_INTERVAL 30_000; // 30 秒 const HEALTH_CHECK_TIMEOUT 3_000; // 3 秒超时 const MAX_RETRIES 3; class RpcFailoverManager { private nodes: RpcNode[] []; private providers: Mapstring, ethers.JsonRpcProvider new Map(); private currentIndex 0; private healthCheckTimer: NodeJS.Timeout | null null; constructor(nodeUrls: string[]) { this.nodes nodeUrls.map((url) ({ url, weight: 1, healthy: true, lastCheck: 0, })); this.startHealthCheck(); } /** * 获取当前可用的 provider。 * 采用轮询 健康检查双重机制 * - 优先使用上次成功的节点减少切换开销 * - 当前节点不健康时跳过并尝试下一个 */ getProvider(): ethers.JsonRpcProvider { const startIndex this.currentIndex; for (let i 0; i this.nodes.length; i) { const idx (startIndex i) % this.nodes.length; if (this.nodes[idx].healthy) { this.currentIndex idx; return this.getOrCreateProvider(idx); } } // 所有节点都不健康——不静默失败让上层感知到问题 throw new Error(RPC_FAILOVER: all nodes unhealthy); } async executeWithRetryT( call: (provider: ethers.JsonRpcProvider) PromiseT ): PromiseT { let lastError: Error | null null; for (let attempt 0; attempt MAX_RETRIES; attempt) { try { const provider this.getProvider(); return await call(provider); } catch (err) { lastError err as Error; // 标记当前节点为不健康下次调用换节点 this.nodes[this.currentIndex].healthy false; } } throw new Error( RPC_FAILOVER: all retries exhausted. Last error: ${lastError?.message} ); } private async startHealthCheck() { const check async () { for (let i 0; i this.nodes.length; i) { const node this.nodes[i]; try { const provider this.getOrCreateProvider(i); const controller new AbortController(); const timeout setTimeout(() controller.abort(), HEALTH_CHECK_TIMEOUT); // 最轻量的检查获取 chainId // 比 eth_blockNumber 更好——不依赖链是否在出块 const chainId await Promise.race([ provider.send(eth_chainId, []), new Promise((_, reject) setTimeout(() reject(new Error(timeout)), HEALTH_CHECK_TIMEOUT) ), ]); clearTimeout(timeout); node.healthy true; } catch { // 单节点失败不影响其他节点检查 node.healthy false; } finally { node.lastCheck Date.now(); } } this.healthCheckTimer setTimeout(check, HEALTH_CHECK_INTERVAL); }; check(); } destroy() { if (this.healthCheckTimer) { clearTimeout(this.healthCheckTimer); } } private getOrCreateProvider(index: number): ethers.JsonRpcProvider { const url this.nodes[index].url; if (!this.providers.has(url)) { this.providers.set( url, new ethers.JsonRpcProvider(url, undefined, { staticNetwork: true, // 避免每次调用都查 network }) ); } return this.providers.get(url)!; } } // 单例——全局共享故障转移逻辑 let instance: RpcFailoverManager | null null; export function getRpcManager(nodeUrls?: string[]): RpcFailoverManager { if (!instance nodeUrls) { instance new RpcFailoverManager(nodeUrls); } if (!instance) { throw new Error(RPC_FAILOVER: not initialized); } return instance; }第二层钱包认证与网络守卫Next.js 中间件运行在 Edge Runtime无法访问浏览器钱包window.ethereum。所以路由守卫必须在客户端层实现// components/WalletGuard.tsx use client; import { useAccount, useChainId, useSwitchChain } from wagmi; import { useRouter, usePathname } from next/navigation; import { useEffect, useRef } from react; /** * 钱包路由守卫组件 * * 设计决策 * - 不阻塞首次渲染用 ref 防止短时间内重复重定向 * - 已连接但链不对和未连接是两种状态处理方式不同 * - pathname 变化时重新检查防止用户通过地址栏跳转绕过守卫 */ const PROTECTED_ROUTES [/dashboard, /swap, /pool, /governance]; const CONNECT_PAGE /connect; export function WalletGuard({ children }: { children: React.ReactNode }) { const { isConnected, isReconnecting } useAccount(); const chainId useChainId(); const { switchChain } useSwitchChain(); const router useRouter(); const pathname usePathname(); const redirectRef useRef(0); const targetChainId Number(process.env.NEXT_PUBLIC_CHAIN_ID) || 1; useEffect(() { // wagmi 重连中不执行守卫逻辑——避免闪烁 if (isReconnecting) return; const needsAuth PROTECTED_ROUTES.some((route) pathname.startsWith(route) ); if (!needsAuth) return; // 未连接重定向到连接页 if (!isConnected) { if (Date.now() - redirectRef.current 2000) { redirectRef.current Date.now(); router.replace(${CONNECT_PAGE}?redirect${encodeURIComponent(pathname)}); } return; } // 已连接但链不对触发链切换 if (chainId ! targetChainId) { switchChain?.({ chainId: targetChainId }); } }, [isConnected, isReconnecting, chainId, pathname, router, switchChain, targetChainId]); // 非受保护路由直接渲染不做任何检查 if (!PROTECTED_ROUTES.some((route) pathname.startsWith(route))) { return {children}/; } // 未连接或重连中不渲染内容连接页会覆盖 if (!isConnected || isReconnecting) { return null; } // 链不对显示切换提示 if (chainId ! targetChainId) { return div正在切换网络.../div; } return {children}/; }第三层RPC Context 注入// providers/RpcProvider.tsx use client; import { createContext, useContext, useEffect, useMemo, useState } from react; import { ethers } from ethers; import { getRpcManager } from /lib/rpc/RpcFailover; const RpcContext createContextethers.JsonRpcProvider | null(null); export function RpcProvider({ children }: { children: React.ReactNode }) { const [provider, setProvider] useStateethers.JsonRpcProvider | null(null); useEffect(() { // 初始化 RPC 管理器 const manager getRpcManager([ process.env.NEXT_PUBLIC_RPC_PRIMARY!, process.env.NEXT_PUBLIC_RPC_FALLBACK_1!, process.env.NEXT_PUBLIC_RPC_FALLBACK_2!, ]); setProvider(manager.getProvider()); return () { manager.destroy(); }; }, []); return RpcContext.Provider value{provider}{children}/RpcContext.Provider; } export function useRpcProvider() { const ctx useContext(RpcContext); if (!ctx) throw new Error(useRpcProvider must be used within RpcProvider); return ctx; }组合layout.tsx 中的中间件链// app/(dapp)/layout.tsx import { RpcProvider } from /providers/RpcProvider; import { WalletGuard } from /components/WalletGuard; import { WagmiConfig } from ./WagmiConfig; export default function DappLayout({ children }: { children: React.ReactNode }) { return ( WagmiConfig RpcProvider WalletGuard{children}/WalletGuard /RpcProvider /WagmiConfig ); }四、边界分析中间件层不是银弹钱包守卫的边界守卫只在客户端生效。Next.js 的middleware.tsEdge Runtime不能检查钱包状态所以 SSR/SSG 页面需要退回到客户端守卫守卫不防 XSS。攻击者如果成功注入脚本可以修改 React 状态绕过守卫。关键操作的安全最终在合约层保证守卫可能导致重复重定向。如果用户拒绝链切换switchChain不会 resolve守卫会持续触发。需要增加用户拒绝状态Provider 注入的边界Context 值变化时所有消费者都会重新渲染。如果 Provider 中包含频繁变化的状态如当前区块高度需要用 useMemo 或拆分 Context 来减少不必要的重渲染ethers.JsonRpcProvider 实例在 SPA 路由切换时不应重建——每次重建意味着 WebSocket 连接的重建如果使用 WebSocket provider。建议将 Provider 提升到 layout 层级RPC 故障转移的边界eth_chainId 健康检查无法发现节点返回过期数据的脑裂问题。更可靠的方案是比较多个节点的最新区块哈希故障转移期间的请求可能丢失。在切换节点的窗口期pending 中的请求可能被静默丢失写操作sendTransaction的故障转移更复杂——交易可能已在待切换的节点中被广播禁用场景纯静态 DApp如展示性 NFT 画廊不需要 RPC 故障转移直接读缓存数据即可服务端渲染SSR的 DApp 页面不能依赖客户端的 RPC 故障转移——SSR 时没有浏览器环境需要服务端直接连接 RPC 或使用索引层五、总结Next.js DApp 的中间件层应从三方面设计RPC 故障转移多节点轮询 健康检查 重试、钱包路由守卫客户端守卫 链切换检测 重定向防抖、Context 注入统一向下传递 wallet 和 provider 状态。核心原则是关注点分离页面组件只处理业务横切关注点由中间件统一管理。RPC 故障转移需增加区块哈希交叉验证防止数据脑裂钱包守卫需处理用户拒绝链切换的状态。写操作的故障转移比读操作复杂需要额外考虑交易广播的非幂等性。