Provider 不是“一个 RPC URL 的包装器”。成熟 DApp 需要把链配置、只读查询、钱包授权、签名、传输协议、故障转移和一致性策略拆开。否则一次 RPC 抖动会被误判为钱包断开,一次切链会污染旧缓存,而盲目重试写请求还可能产生重复业务操作。
一、本文解决什么问题
前端通常从一段简单代码开始:
const provider = new Provider(RPC_URL);
const balance = await provider.getBalance(address);随着业务增长,它很快需要同时处理:
- 公共链数据读取;
- 注入钱包与 WalletConnect;
- 多条链配置;
- HTTP 查询和 WebSocket 订阅;
- RPC 限流、超时与故障;
- 多节点高度不一致;
- 请求重试与去重;
- 交易签名和广播;
- 页面切链、切账户和后台恢复;
- 区块重组导致的数据回退。
如果所有能力都放进一个全局 provider,常见问题包括:
- 用户没有连接钱包时,页面连公开数据都无法展示;
- 钱包当前 RPC 故障被错误展示成“账户断开”;
- 合约读取依赖用户钱包速度和限额;
- 同一个请求同时打到多个 Provider,却选中不同区块状态;
- HTTP Fallback 切换后,Nonce、Receipt 和 Logs 出现前后矛盾;
- WebSocket 重连后漏事件或重复事件;
- 对
eth_sendRawTransaction进行普通重试,无法解释交易是否已广播; - 组件重复渲染触发几十次相同 RPC;
- 把 Signer 与 Provider 混为一谈,导致只读模块意外获得签名能力。
本文覆盖大纲中的:
- Transport
- HTTP / WebSocket
- Chain Configuration
- Public Client
- Wallet Client
- Signer
- Provider Fallback
- RPC Quorum
- Rate Limit
- Retry
- Request Deduplication
核心结论
- Public Client 负责链上公共数据与模拟,Wallet Client 负责账户授权和钱包请求,Signer 只负责产生签名;三者不应因 API 方便而混成一个安全边界。
- HTTP 适合请求/响应和可控重试,WebSocket 适合低延迟订阅,但断线恢复必须按区块范围补数,不能只重新订阅。
- Chain Configuration 是所有 RPC、地址、Explorer 和 Finality 策略的根上下文,必须经过可信配置管理,不能直接接受用户输入。
- Provider Fallback 解决可用性,不自动解决正确性。切换节点可能带来 Head、Mempool、Archive 能力和日志结果差异。
- RPC Quorum 只适合可定义等价关系的读请求;对
latest、Pending、Gas Estimate 和 Trace 盲目多数投票可能得出没有意义的结果。 - Rate Limit 应在客户端、服务端代理和供应商三个层级治理,并按方法成本区分预算。
- Retry 必须基于幂等性、错误类型和截止时间;签名弹窗、交易意图和非幂等业务请求不能做无条件自动重试。
- Request Deduplication 只合并语义完全相同且处于同一链状态上下文的请求,不能跨账户、区块标签或 Provider 世代复用。
二、Provider 架构中的对象边界
2.1 推荐分层
每层职责:
| 层 | 负责 | 不负责 |
|---|---|---|
| Feature/UI | 展示状态、发出用户意图 | 不直接拼接任意 JSON-RPC |
| Query Service | 缓存、区块绑定、数据组合 | 不持有私钥 |
| Transaction Service | 模拟、构造、跟踪交易状态 | 不绕过钱包确认 |
| Public Client | 公共读取、日志、模拟、费用数据 | 不要求用户账户授权 |
| Wallet Client | 请求账户、切链、签名和发送 | 不作为高吞吐公共读取源 |
| Signer | 对明确 Payload 签名 | 不决定业务目标和 RPC 路由 |
| Transport | 超时、序列化、限流、重试、路由 | 不解释业务成功 |
| Chain Config | 网络身份和可信基础设施 | 不从不可信 URL 动态覆盖 |
2.2 “Provider”一词为什么容易混乱
不同库中 Provider 可能表示:
- EIP-1193 钱包 Provider;
- JSON-RPC 网络 Provider;
- React Context Provider;
- Fallback Provider;
- 节点服务商;
- 数据源抽象。
代码命名应更具体:
type PublicRpcClient = { /* read/simulate */ };
type WalletProvider = { request(args: RpcRequest): Promise<unknown> };
type TransactionSigner = { signTransaction(tx: UnsignedTx): Promise<Hex> };
type RpcTransport = { send(request: JsonRpcRequest): Promise<JsonRpcResponse> };命名清楚会直接改善权限评审和故障诊断。
2.3 数据读取不应依赖钱包连接
首页 TVL、Token 信息、价格和合约状态属于公开数据。它们应通过 Public Client 获取,使用户在未安装钱包、未授权账户或钱包锁定时仍能浏览。
只有下列行为需要 Wallet Client:
- 获取当前授权账户;
- 请求切链或添加网络;
- 签署交易或消息;
- 通过钱包发送交易;
- 响应账户、链和连接事件。
三、Transport
3.1 Transport 的稳定职责
Transport 把结构化 JSON-RPC Request 发送到目标 Endpoint,并返回 Response。它应统一处理:
- JSON 序列化和反序列化;
- Request ID;
- HTTP/WS 生命周期;
- 超时和取消;
- 并发限制;
- Rate Limit;
- Retry;
- Batch;
- 指标、Trace 和安全日志;
- Fallback/Quorum 路由;
- 错误归一化。
Transport 不应把 eth_call Revert 当作网络重试错误,也不应把 HTTP 200 直接解释为 RPC 成功。JSON-RPC Error 可能包含在成功的 HTTP 响应中。
3.2 结构化请求
type JsonRpcRequest = {
jsonrpc: '2.0';
id: number | string;
method: string;
params?: readonly unknown[] | object;
};
type RpcRequestContext = {
chainId: number;
deadlineMs: number;
consistency: 'latest' | 'safe' | 'finalized' | 'pinned-block';
idempotency: 'read' | 'raw-broadcast' | 'interactive' | 'unknown';
priority: 'foreground' | 'background';
};Context 不一定发给节点,但用于路由和治理。缺少 Chain ID 与一致性标签的裸请求很难安全 Fallback。
3.3 错误分类
至少区分:
TransportError
- DNS / TLS / Connection
- Timeout / Abort
- HTTP 429 / 5xx
- WebSocket Closed
ProtocolError
- Invalid JSON
- JSON-RPC ID Mismatch
- Malformed Result
RpcError
- Method Not Found
- Invalid Params
- Execution Reverted
- Rate Limited by Provider
ConsistencyError
- Wrong Chain
- Head Too Old
- Block Hash Mismatch
- Quorum Not Reached只有部分错误适合重试。
3.4 超时不是失败事实
读请求超时通常可以换节点重试;广播请求超时则可能已经被节点接受。对于 eth_sendRawTransaction:
- 广播前本地计算 Transaction Hash;
- 超时后按 Hash 查询;
- 可以重播完全相同的 Raw Transaction;
- 不能因为超时就重新分配 Nonce 创建另一笔业务交易。
Transport 应把“响应未知”保留为独立状态。
四、HTTP 与 WebSocket
4.1 HTTP 的特点
HTTP RPC 适合:
eth_call;eth_getBalance;eth_getLogs;eth_getTransactionReceipt;- Gas/Fee 查询;
- 交易广播;
- 服务端 Batch 和高并发读取。
优势:
- 请求生命周期清晰;
- 易于设置超时、代理、缓存和重试;
- 无长期连接状态;
- 服务端与 Serverless 兼容较好。
代价是实时事件通常需要轮询。
4.2 WebSocket 的特点
WebSocket 适合通过 eth_subscribe 等机制获取:
- New Heads;
- Logs;
- 某些节点支持的 Pending Transaction 通知。
优势是低延迟推送;代价是:
- 长连接会断开;
- 代理、移动网络和后台策略可能终止连接;
- 订阅 ID 只在特定连接生命周期内有效;
- 重连可能漏事件;
- 同一事件可能重复推送;
- 服务商对订阅数量和消息速率限制更严格。
4.3 WebSocket 不是可靠消息队列
错误实现:
socket.on('log', (log) => database.insert(log));
socket.on('close', reconnect);连接断开到重连之间的 Logs 会永久缺失。正确策略是保存 Cursor:
lastProcessedBlockNumber
lastProcessedBlockHash
lastLogIndex重连后先通过 HTTP eth_getLogs 回补缺口,再从新的 Head 继续订阅。
4.4 订阅恢复流程
若上次 Block Hash 不再规范,还要回退到共同祖先并处理 Removed Log。
4.5 混合 Transport
常见方案:
- HTTP 负责查询、模拟、Receipt 和回补;
- WebSocket 只负责提示“可能有新块/新日志”;
- 收到推送后仍通过 HTTP 按 Block Hash 拉取权威详情。
这比把 WebSocket 消息直接作为最终数据库事实更可靠。
4.6 移动端与浏览器后台
页面进入后台后,浏览器可能冻结 Timer 或断开 WebSocket。恢复前台时必须:
- 检查连接世代;
- 获取最新 Head;
- 回补后台期间区块;
- 取消旧订阅回调;
- 防止多个 Socket 同时活跃;
- 重新评估 Rate Limit 与批量回补范围。
五、Chain Configuration
5.1 配置是信任根
Chain Configuration 至少包含:
type ChainConfig = {
chainId: number;
name: string;
nativeCurrency: {
name: string;
symbol: string;
decimals: number;
};
rpc: {
http: readonly RpcEndpoint[];
webSocket?: readonly RpcEndpoint[];
};
blockExplorers: readonly string[];
contracts: Readonly<Record<string, `0x${string}`>>;
finalityPolicy: FinalityPolicy;
capabilities: ChainCapabilities;
};不要把 RPC、Explorer 和合约地址散落在组件里。
5.2 Chain ID 不足以证明 RPC 正确
连接 Endpoint 后至少调用 eth_chainId 校验网络,但恶意 RPC 可以谎报 Chain ID。高价值场景可进一步核对:
- 已知 Genesis/Checkpoint Block Hash;
- 关键合约 Code Hash;
- 当前 Head 与其他独立节点差异;
- 客户端/网络身份方法;
- 可信部署清单。
这是提高置信度,不是从单个不可信 RPC 获得密码学最终证明。
5.3 不要接受任意 RPC URL
如果 DApp 允许 URL 参数覆盖 RPC,攻击者可以构造钓鱼链接,使页面:
- 隐藏真实余额或交易;
- 返回伪造合约调用结果;
- 提供恶意 Gas/Nonce 数据;
- 诱导用户签署错误目标链交易;
- 收集地址和请求隐私。
自定义网络功能应隔离为高级设置,明确警告并验证协议、Chain ID、私网地址和权限。
5.4 Capability 不应靠失败探测
链配置可以声明:
- 是否支持 EIP-1559;
- 是否提供
safe/finalized标签; - 是否支持 WebSocket;
- 是否提供 Archive/Trace;
- L2 Fee 获取方式;
- 最大 Logs Range;
- Bundler/Paymaster EntryPoint 版本。
运行时仍需检测,但不要在每次用户请求时靠不断调用不支持方法来猜能力。
5.5 配置版本化
合约升级、RPC 下线、Explorer 迁移都需要配置发布。配置应:
- 有 Schema Validation;
- 有版本和变更记录;
- 支持灰度;
- 可快速撤销故障 Endpoint;
- 区分构建时可信默认值与运行时远程配置;
- 对远程配置签名或通过可信发布渠道保护。
六、Public Client
6.1 职责
Public Client 面向无需用户授权的链数据:
- Block、Transaction、Receipt;
- Balance、Code、Storage;
- Contract Read;
- Logs;
- Gas Estimate 与 Fee;
eth_callSimulation;- ENS/Name Resolution;
- Chain 状态与 Finality。
它应由应用控制的 RPC 配置驱动,而不是默认绑定当前钱包 RPC。
6.2 为什么不使用 Wallet Provider 做所有读取
钱包 Provider:
- 需要安装和授权;
- Endpoint、限流和网络由钱包控制;
- 可能不支持 Archive、Trace 或大范围 Logs;
- 切链会改变读取上下文;
- 用户锁定或 Disconnect 会影响可用性;
- 不适合后台服务和 SSR。
Public Client 让数据层与钱包生命周期解耦。
6.3 区块绑定读取
组合多个 RPC 时,如果都使用 latest,结果可能来自不同高度:
balance at block 100
allowance at block 101
pool reserve at block 102对于必须一致的页面或模拟,应先读取一个 Block Number/Hash,再让后续调用绑定同一区块:
const block = await publicClient.getBlock({ blockTag: 'latest' });
const [balance, allowance] = await Promise.all([
publicClient.readContract({ ...balanceCall, blockNumber: block.number }),
publicClient.readContract({ ...allowanceCall, blockNumber: block.number }),
]);节点必须仍保留该历史状态,否则可能返回 Missing Trie Node/Archive 能力错误。
6.4 Multicall 与 JSON-RPC Batch
两者不同:
- JSON-RPC Batch:一个 HTTP 请求包含多个独立 RPC,请求可能仍在不同处理时点执行;
- Multicall 合约:在一次
eth_call执行中读取多个合约,天然共享同一 EVM 状态上下文。
Multicall 还要校验合约地址、链部署和子调用失败语义。不能默认所有 Multicall 都原子 Revert 或返回同一格式。
6.5 Public Client 不是业务缓存
Client 可以提供底层 Dedup 和 Batch,但 React Query/业务缓存仍负责:
- Stale Time;
- Account/Chain Key;
- Block-based Invalidation;
- UI Loading/Error;
- SSR Hydration;
- 乐观更新和 Reorg 回退。
不要让 Transport 缓存无限期取代业务状态管理。
七、Wallet Client
7.1 职责
Wallet Client 包装用户钱包能力:
eth_accounts/eth_requestAccounts;- 账户与链事件;
- 请求切链和添加网络;
- 发送交易;
- 签署消息与 Typed Data;
- 获取钱包特定 Capability;
- 管理 WalletConnect Session。
它的核心上下文是:
connectorId + providerInstance + sessionId + chainId + account任何一项变化都可能使 Pending 请求失效。
7.2 Wallet Client 不应静默切换账户
交易前必须核对:
- 当前 Provider 是否仍是用户选择的钱包;
- 当前账户是否等于交易
from; - Chain ID 是否符合 Intent;
- Session 是否批准相应方法;
- 用户是否重新确认过变化后的内容。
如果 accountsChanged 在模拟后发生,旧 Simulation 必须失效。
7.3 钱包 RPC 与 Public RPC 交叉验证
构造交易时可以用 Public Client 模拟,用 Wallet Client 签名。但要避免两个数据源高度差异过大:
- Public RPC 返回最新 Nonce;
- 钱包 RPC 看到不同 Pending Pool;
- Public RPC 模拟通过;
- 钱包实际发送时状态已经改变。
最终交易字段应由钱包明确展示,发送前重新验证 Chain/Account,Receipt 则通过多个可信来源跟踪。
7.4 业务登录不是 Wallet Client 状态
钱包暴露账户不等于后端登录。SIWE 等签名认证应生成独立业务 Session,并在账户变化、域变化或策略需要时失效。
八、Signer
8.1 Signer 的最小职责
Signer 接收规范化 Payload 并返回签名:
interface TransactionSigner {
getAddress(): Promise<`0x${string}`>;
signTransaction(transaction: UnsignedTransaction): Promise<Hex>;
signMessage(message: SignableMessage): Promise<Hex>;
signTypedData(data: TypedDataPayload): Promise<Hex>;
}具体方法因库而异。权限边界比接口名称重要:Signer 不应自行选择目标合约、金额或 Chain。
8.2 Signer 类型
- Injected Wallet Signer;
- WalletConnect Remote Signer;
- Local Keystore Signer;
- Hardware Wallet Signer;
- HSM/MPC Signer;
- Smart Account Signer/Validator;
- Test Signer。
不同 Signer 的生命周期、算法、交互和错误不同,不应通过一个 privateKey?: string 抽象所有情况。
8.3 Signer 与 Transport 解耦
离线 Signer 可以只签名不广播;Watch-only Public Client 可以只读取不签名。拆分后可以:
- 在离线设备签署;
- 通过多个 RPC 广播同一 Raw Transaction;
- 在测试中替换 Fake Signer;
- 防止只读模块意外获取签名权限;
- 清晰记录“谁签名、谁广播、谁跟踪”。
8.4 不要自动重试交互签名
用户拒绝、钱包关闭或硬件设备超时属于交互结果。自动重弹签名可能造成:
- 多个并行请求;
- 用户误签;
- 旧 Intent 在切链后继续;
- WalletConnect Request ID 混乱。
重试必须由新的明确用户操作触发,并重新验证 Intent。
九、Provider Fallback
9.1 Fallback 解决什么
当主 RPC 超时、限流或不可用时,路由到备用 Endpoint,提高读取和广播可用性。
9.2 Fallback 不等于任意换节点
Endpoint 可能不同:
- Head 高度;
- Mempool 内容;
- Archive 历史;
- Trace API;
- Logs Range;
- Pending State;
- Client 实现;
- Rate Limit;
- L2 扩展 RPC。
路由前必须按 Capability 过滤。
9.3 健康检查
健康度不能只看 TCP/HTTP 200。至少监控:
eth_chainId;- 当前 Head Number/Hash;
- Head 与参考节点的延迟;
- P50/P95/P99;
- 错误率、429、超时;
- 历史状态能力;
- Logs/Trace 方法成功率;
- WebSocket 消息停滞时间。
故障节点进入熔断,经过冷却和探测后再恢复流量。
9.4 Hedged Request
高延迟读请求可以先发主节点,超过短延迟再向备用节点发送,取第一个满足校验的结果。这能降低尾延迟,但会增加请求成本和供应商限额消耗。
不适合:
- 用户签名请求;
- 会产生副作用的自定义 RPC;
- 未定义幂等性的服务方法;
- 隐私敏感交易意图。
9.5 Sticky Routing
某些流程应粘住同一节点或节点组:
- Pending Nonce 与广播;
- Transaction Hash 后续查询;
- 分页 Logs;
- Debug Trace;
- 基于 Pending State 的 Simulation。
完全随机 Fallback 会让状态看起来来回跳变。
十、RPC Quorum
10.1 什么是 Quorum
Quorum 同时查询多个独立 RPC,只有达到阈值的相容结果才返回。目的可能是:
- 降低单节点错误或恶意响应风险;
- 检测节点落后;
- 提升高价值读请求置信度;
- 观察链分叉或服务商异常。
10.2 先定义等价关系
不同方法不能统一 JSON.stringify(result) 投票。
| 方法 | 合理比较方式 | 风险 |
|---|---|---|
eth_chainId |
精确相等 | 恶意节点仍可伪报 |
| 固定 Block Hash 的 Block | Hash、Parent、State Root 精确相等 | 节点可能尚未保存该块 |
| 固定 Block 的 Balance | 规范化数量相等 | 需要 Archive 状态 |
latest Block Number |
容许高度窗口并检查 Hash 链 | 同时到达时间不同 |
| Gas Estimate | 不能简单要求精确相等 | 客户端算法与状态不同 |
| Fee Suggestion | 使用分位数/策略,不是事实投票 | 本身就是建议值 |
| Pending Nonce | 不适合全局多数 | Mempool 是本地视图 |
| Logs 固定范围 | 按 Block Hash、Tx Index、Log Index 比较 | Reorg 与节点限制 |
10.3 固定区块后再 Quorum
对于高价值读取:
- 多节点确定一个共同认可的 Block Hash;
- 对该 Block Hash/Number 读取状态;
- 对规范化结果投票;
- 达不到阈值则返回不确定状态,而不是随便选最快结果。
这比同时查询三个节点的 latest 更有意义。
10.4 节点独立性
三个 URL 可能都属于同一供应商、同一上游集群或同一客户端实现,故障相关性很高。Quorum 设计应考虑:
- 不同供应商;
- 不同地域;
- 不同客户端实现;
- 自建节点与第三方节点组合;
- 共同依赖的 Cloud/Network;
- 数据源是否实际共享上游。
10.5 Quorum 的代价
- RPC 成本成倍增长;
- 延迟由第 K 个响应决定;
- 结果归一化复杂;
- Reorg 时容易短暂无 Quorum;
- 隐私暴露给更多服务商;
- 对 Trace/大 Logs 请求代价很高。
只应用于高风险、可验证的关键读取,而不是所有首页请求。
十一、Rate Limit
11.1 限流来源
- 浏览器自身并发限制;
- 应用 API Gateway;
- RPC 服务商按 Key/IP/Method 计费;
- 节点 CPU、内存和数据库保护;
- WebSocket Subscription 数量;
- Bundler/Paymaster 业务配额。
HTTP 429 是常见信号,但部分服务会返回 JSON-RPC Error 或 503。应按供应商文档识别,同时保留通用退避策略。
11.2 按成本分级
不同方法成本差异很大:
| 级别 | 示例 | 治理策略 |
|---|---|---|
| 低 | eth_chainId、固定块简单读取 |
可缓存、可 Dedup |
| 中 | eth_call、Receipt、Block |
并发限制、短缓存 |
| 高 | 大范围 eth_getLogs |
分块、服务端执行 |
| 极高 | Trace、Archive State、复杂模拟 | 单独队列、预算和授权 |
不能只按“每秒请求数”限流。
11.3 客户端 Token Bucket
type RpcBudget = {
maxConcurrent: number;
tokensPerSecond: number;
burst: number;
};前台交互请求优先于后台刷新。页面不可见时降低轮询频率,恢复时合并刷新,避免所有组件同时打满 RPC。
11.4 服务端 RPC Proxy
将受保护 API Key 放在浏览器代码中无法保密。生产 DApp 可使用服务端代理实现:
- 隐藏供应商凭据;
- 用户/IP/Session 限流;
- Method Allowlist;
- 参数和 Block Range 限制;
- 缓存与 Dedup;
- 多供应商路由;
- 指标与成本治理。
Proxy 不能允许任意 JSON-RPC 透传,否则可能成为公开 Trace/Archive 滥用入口。
11.5 Backpressure
当队列超过阈值时,应丢弃过时后台任务或返回可解释错误,而不是无限排队。区块 100 的余额刷新在区块 105 到来后通常已无价值。
十二、Retry
12.1 按幂等性分类
| 请求类型 | 自动重试 | 条件 |
|---|---|---|
| 固定区块只读 | 通常可以 | 超时、连接、部分 5xx |
latest 只读 |
可以,但结果可能变化 | 标记新 Snapshot |
eth_call |
可以 | 同 Chain、Block、参数 |
eth_getLogs |
可以 | 固定 Block Range |
eth_sendRawTransaction |
可重播相同 Raw Tx | 不能生成新交易 |
eth_sendTransaction |
不应自动重弹钱包 | 新用户意图触发 |
wallet_switchEthereumChain |
不自动反复弹窗 | 用户明确重试 |
| 签名请求 | 不自动重试 | 重新校验 Intent |
| 未知自定义 RPC | 默认不重试 | 先确认副作用 |
12.2 指数退避与抖动
delay = min(cap, base * 2^attempt) + random_jitter还应受总 Deadline 约束。例如页面查询只允许 4 秒,不能执行 5 次各 3 秒的重试。
12.3 不应重试的错误
- Invalid Params;
- Method Not Found;
- Execution Reverted;
- User Rejected;
- Unauthorized;
- Wrong Chain;
- 明确的业务校验失败;
- 超过用户费用或安全策略。
换节点可能用于确认错误是否一致,但不能把确定性 Revert 当网络抖动无限重试。
12.4 Retry Storm
当 RPC 故障时,所有客户端立即重试会放大故障。需要:
- 指数退避与随机抖动;
- 熔断;
- 全局并发上限;
- 后台任务降级;
- 共享失败结果短缓存;
- 服务端 Retry Budget。
12.5 Retry 与 Fallback 顺序
可以按请求类型选择:
快速读:Primary 一次 -> Secondary -> 退避重试
Archive:具备能力的 Primary -> 同能力 Secondary
广播:Primary + 可选同 Raw Tx 多播 -> 按 Hash 查询
Trace:单节点长 Deadline -> 明确失败,不盲目跨节点风暴十三、Request Deduplication
13.1 为什么会产生重复请求
- 多个组件读取同一余额;
- React Strict Mode 开发环境重复执行;
- 页面切换和 Hydration;
- Block Event 同时触发多个 Query;
- 用户快速切换 Tab;
- WebSocket 重连与轮询同时刷新;
- Fallback 层与业务层都做重试。
13.2 In-flight Dedup
只合并正在执行的相同请求:
class InflightDeduper {
private readonly inflight = new Map<string, Promise<unknown>>();
run<T>(key: string, request: () => Promise<T>): Promise<T> {
const existing = this.inflight.get(key);
if (existing) return existing as Promise<T>;
const promise = request().finally(() => {
if (this.inflight.get(key) === promise) {
this.inflight.delete(key);
}
});
this.inflight.set(key, promise);
return promise;
}
}13.3 Dedup Key
Key 至少包含:
chainId
method
canonical params
blockRef / consistency mode
provider generation
account if semantics depend on itJSON 对象需要稳定序列化。数组顺序、十六进制 Quantity 规范化、地址大小写和默认参数都要处理。
13.4 不能 Dedup 的请求
- 钱包授权;
- 用户签名;
- 切链;
- 业务创建订单;
- 不同 Intent 的交易发送;
- Pending State 下语义可能不同的请求;
- 隐私或权限上下文不同的请求。
eth_sendRawTransaction 对完全相同 Raw Transaction 可幂等重播,但不应与普通读 Dedup 使用同一策略。
13.5 Dedup、Cache 与 Batch 的区别
- Dedup:合并同一时刻的相同请求;
- Cache:复用过去的结果;
- Batch:一次传输发送多个不同请求;
- Multicall:在同一 EVM 调用上下文执行多个读取。
四者可以组合,但解决的问题不同。
13.6 取消语义
多个调用者共享一个 In-flight Promise 时,一个组件取消不应直接 Abort 底层请求,除非所有订阅者都取消。需要引用计数或让取消只忽略调用者自己的结果。
十四、一致性策略
14.1 Block Tag 的语义
常见标签:
latest:当前节点认为的最新规范块;pending:包含节点本地 Pending 状态,非全局共识;safe/finalized:仅在目标链和节点支持时使用;- 明确 Block Number;
- EIP-1898 风格 Block Hash 引用,取决于方法和节点支持。
同一个标签在不同节点同一时刻可能对应不同 Block Hash。
14.2 页面一致性等级
type ReadConsistency =
| { mode: 'fast-latest' }
| { mode: 'pinned'; blockNumber: bigint; blockHash?: Hex }
| { mode: 'safe' }
| { mode: 'finalized' }
| { mode: 'quorum'; threshold: number };首页行情可用 Fast Latest;资产结算、证明生成和跨链确认需要更强策略。
14.3 Receipt 跟踪的一致性
获取 Receipt 后保存 blockHash,继续验证该 Block Hash 是否仍在规范链。Provider Fallback 时不能只比较 Block Number。
14.4 Cache Key 必须包含 Block Context
固定区块结果可以长缓存;latest 结果必须随新块失效。切换 Provider 不一定需要清空固定 Block Hash 数据,但必须清空 Pending 和未绑定区块的结果。
十五、端到端请求流程
15.1 Contract Read
15.2 Contract Write
Public Client 和 Wallet Client 在流程中协作,但权限和故障域保持独立。
十六、常见错误案例
16.1 全站只使用 window.ethereum
未连接钱包时无法读取,且所有查询受钱包 RPC 和切链影响。应使用独立 Public Client。
16.2 Public Client 与 Wallet Client 共用一个可变全局对象
用户切链会让旧页面请求落到新链。Client 应绑定不可变 Chain Config,切链创建新上下文。
16.3 WebSocket 重连后只重新订阅
断线期间事件会丢失。必须按最后处理 Block 回补。
16.4 Fallback 到不支持 Archive 的节点
历史 eth_call 突然失败。Endpoint 必须声明并验证 Capability。
16.5 三个 latest 结果直接多数投票
节点高度不同,结果不相等并不表示恶意。应先确定共同 Block Hash。
16.6 对所有错误做三次 Retry
Revert、Invalid Params 和 User Rejected 不会因重试消失,还会制造弹窗和流量风暴。
16.7 Dedup Key 不包含 Chain ID
切链后可能复用上一条链的余额或合约结果。
16.8 把 HTTP 200 当成功
JSON-RPC Error 通常也可以通过 HTTP 200 返回。必须解析 Response Body。
16.9 RPC Key 写进前端后认为受环境变量保护
构建进浏览器 Bundle 的 Key 对用户可见。需要服务端 Proxy 或使用可公开、受域和限额约束的凭据。
16.10 广播超时后构造新交易
原交易可能已被接受。应按本地 Hash 查询或重播相同 Raw Transaction。
十七、工程实现建议
17.1 Client Registry
class ChainClientRegistry {
private readonly publicClients = new Map<number, PublicClient>();
getPublicClient(chainId: number): PublicClient {
const existing = this.publicClients.get(chainId);
if (existing) return existing;
const config = getTrustedChainConfig(chainId);
const client = createPublicClient(config);
this.publicClients.set(chainId, client);
return client;
}
}Public Client 按 Chain ID 固定;Wallet Client 按 Connector/Session 生命周期创建,不应放进同一永久单例。
17.2 Transport Middleware
Request
-> Schema Validation
-> Capability Routing
-> Cache / In-flight Dedup
-> Rate Limit / Priority Queue
-> Timeout
-> HTTP or WebSocket Transport
-> Retry / Fallback
-> Response Validation
-> Metrics / Safe Logging顺序很重要。例如先 Retry 再限流会绕过预算,先 Cache 再验证 Chain Context 会污染数据。
17.3 日志脱敏
不要记录:
- Raw Transaction;
- 完整 Typed Data;
- WalletConnect URI;
- 私有 RPC Key;
- 用户签名;
- Authorization Header;
- 可能包含隐私的 Calldata。
可以记录 Hash、Method、Chain ID、Endpoint ID、Latency、Error Code 和 Block Context。
17.4 SSR 边界
服务端渲染可使用 Public Client,但不能访问浏览器 Wallet Provider。Hydration 后再发现钱包,并避免服务端链数据与客户端 latest 高度差造成闪烁。
对于固定区块数据,可把 Block Number 一并 Hydrate,客户端先展示同一 Snapshot,再随新区块刷新。
十八、测试与验证方法
18.1 Transport Contract Test
[ ] HTTP 200 + JSON-RPC Error 被识别为 RpcError
[ ] Response ID 不匹配时拒绝
[ ] Timeout 可取消且不会泄漏连接
[ ] 429/5xx 按策略退避
[ ] Revert 不自动网络重试
[ ] 同 Raw Transaction 可安全重播
[ ] 错误日志不含密钥、签名和完整 Calldata18.2 Fallback 故障注入
- Primary DNS 失败;
- Primary 延迟但最终成功;
- Primary Head 落后;
- Secondary Chain ID 错误;
- Archive 请求被路由到 Full Node;
- 所有 Endpoint 同时 429;
- 一个节点返回格式错误;
- 节点在 Reorg 分支。
18.3 WebSocket 测试
- 订阅 Logs;
- 强制断开连接;
- 断线期间生成多个区块和事件;
- 重连并 HTTP 回补;
- 验证无丢失、无重复;
- 制造 Reorg,验证 Removed/回退;
- 页面后台后恢复,确保只有一个活跃 Socket。
18.4 Quorum 测试
- 三节点高度差 1~2 个块;
- 固定 Block Hash 查询一致;
- 一个节点伪造/错误结果;
- 两节点共享同一上游故障;
- Reorg 期间暂时无法达到阈值;
- Gas Estimate 返回不同值,不应做精确多数;
- Pending Nonce 不应进入普通 Quorum。
18.5 Rate Limit 与 Retry Storm
在测试环境返回连续 429,验证:
- 指数退避和 Jitter;
- 全局并发下降;
- 后台请求被丢弃;
- 前台交易跟踪仍有预算;
- Circuit Breaker 生效;
- 服务恢复后逐步放量,而不是瞬时洪峰。
18.6 Dedup 测试
- 100 个组件同时请求同一固定区块余额,只产生一次 RPC;
- 不同 Chain ID 不合并;
- 不同 Block Number 不合并;
- 不同账户/Calldata 不合并;
- 一个订阅者取消不影响其他订阅者;
- 请求失败后 In-flight Key 被清理;
- Wallet 签名请求永不被底层读请求 Deduper 合并。
18.7 性能与可用性指标
rpc_request_latency{chain,method,endpoint,status}
rpc_error_rate{chain,method,error_type}
rpc_rate_limit_count{endpoint}
rpc_fallback_count{from,to,reason}
rpc_head_lag{endpoint}
rpc_quorum_failure{method}
ws_disconnect_count{endpoint}
ws_gap_blocks
request_dedup_saved_count{method}
retry_attempts{method,reason}指标必须控制标签基数,不能把地址、Transaction Hash 或完整 Method Params 当作无限 Cardinality Label。
十九、方案选择
| 场景 | 推荐架构 | 主要代价 |
|---|---|---|
| 小型只读页面 | 单 Public Client + HTTP + 基础 Retry | 单 RPC 可用性 |
| 普通 DApp | Public/Wallet Client 分离 + HTTP Fallback | 配置和状态管理 |
| 实时交易界面 | HTTP 查询 + WS 提示 + 区块回补 | 重连与去重复杂度 |
| 高价值资产页面 | 固定区块读取 + 关键数据 Quorum | 延迟和 RPC 成本 |
| 大规模前端 | Server RPC Proxy + Cache/Dedup/Rate Limit | 服务端运维 |
| 索引与历史分析 | 专用 Indexer + Archive RPC | 数据管道和 Reorg 处理 |
| 自动交易服务 | Sticky RPC + 多播广播 + Nonce Manager | 一致性和资金风险 |
不要用 RPC Quorum 替代 Indexer,也不要用 WebSocket 替代可回放事件存储。每层解决的问题不同。
二十、上线检查清单
[ ] Public Client 与 Wallet Client 已分离
[ ] Signer 不被只读模块持有
[ ] 每个 Client 绑定明确 Chain ID 和可信配置
[ ] Endpoint 启动时验证 Chain ID、Head 和 Capability
[ ] HTTP 请求有超时、取消和错误分类
[ ] WebSocket 断线后按区块回补,不只重新订阅
[ ] Fallback 节点按 Archive/Trace/WS 等能力路由
[ ] Pending、Receipt 和 Logs 流程使用合适 Sticky Routing
[ ] Quorum 只用于定义了等价关系的关键读取
[ ] 高价值读取先固定 Block Hash/Number
[ ] Rate Limit 按 Method 成本和优先级治理
[ ] 浏览器中没有可滥用的私有 RPC Key
[ ] Retry 仅用于可重试错误和幂等操作
[ ] 广播超时按本地 Transaction Hash 查询
[ ] Request Dedup Key 包含 Chain、Params、Block 与 Client 世代
[ ] 签名、授权和切链请求不自动 Dedup/Retry
[ ] Cache、Dedup、Batch 和 Multicall 职责清晰
[ ] 日志不包含 Raw Tx、签名、URI 和敏感 Calldata
[ ] Receipt 保存 Block Hash 并支持 Reorg 回退
[ ] 已通过断网、429、节点落后、错链和重组演练二十一、总结
Web3 Provider 架构的核心,是把“从哪里读链”“由谁授权”“在哪里签名”和“如何容错”拆成独立、可验证的能力。
真正需要记住的是:
- Public Client 面向公开链数据,Wallet Client 面向用户钱包授权,Signer 只产生签名。
- Transport 负责通信治理,不负责判断业务成功;HTTP 200 也可能包含 JSON-RPC Error。
- HTTP 更适合可靠请求与回补,WebSocket 更适合低延迟提示;实时订阅必须搭配 Cursor 和区块回放。
- Chain Configuration 是信任根,RPC URL、合约地址、能力和 Finality 策略必须集中版本化。
- Fallback 提高可用性,但节点之间存在 Head、Mempool 和能力差异,需要校验和 Sticky Routing。
- Quorum 必须先定义结果等价关系,
latest、Pending 和 Gas Estimate 不能做幼稚的字符串多数投票。 - Rate Limit、Retry 与 Dedup 是一套协作机制:限流保护预算,重试处理瞬时故障,去重减少重复工作。
- 任何缓存和请求合并都必须绑定 Chain、Block、Account 和 Provider 世代,才能避免跨链和陈旧状态污染。
好的 Provider 层不是把错误全部隐藏,而是能准确回答:请求发往哪条链、基于哪个区块、经过哪个节点、为什么重试、结果具有什么一致性保证。
问答复盘
Q1:为什么 DApp 不应使用钱包 Provider 读取所有公开数据?
答: 钱包 Provider 受用户授权、切链、钱包 RPC 和限流影响。独立 Public Client 能让只读数据在未连接钱包时仍可用,并保持固定链上下文。
Q2:WebSocket 重新连接并恢复订阅后,是否就不会丢事件?
答: 不能保证。断线期间的事件需要根据上次处理区块通过 HTTP 回补,并检查 Reorg、去重和排序。
Q3:Provider Fallback 为什么不能只选择最快返回的节点?
答: 最快节点可能落后、错链或缺少 Archive/Trace 能力。必须先校验 Chain、Head、Capability 和结果上下文。
Q4:三个 RPC 对 latest Balance 返回不同值,能否直接多数投票?
答: 不宜。节点可能处于不同区块。应先确定共同 Block Hash/Number,再比较该固定状态下的余额。
Q5:eth_estimateGas 是否适合使用精确 Quorum?
答: 通常不适合。节点状态、客户端算法和估算策略可能产生不同但合理的结果,应采用风险上限、模拟和异常比较,而不是要求字节级相等。
Q6:哪些请求不能自动 Retry?
答: 用户签名、账户授权、切链、明确 Revert 和未知副作用请求不能无条件重试。eth_sendRawTransaction 只能重播同一 Raw Transaction。
Q7:Request Deduplication 与 Cache 有什么区别?
答: Dedup 合并同时进行的相同请求;Cache 复用过去结果。前者通常只活到 Promise 结束,后者需要明确过期和区块失效策略。
Q8:为什么 Dedup Key 必须包含 Block Context?
答: 相同方法和参数在不同区块可能返回不同状态。忽略 Block 会把旧链状态错误复用到新 Snapshot。
Q9:HTTP 返回 200 是否代表 JSON-RPC 成功?
答: 不代表。JSON-RPC Error 常通过 HTTP 200 返回,Transport 必须解析 result 与 error,并验证 Response ID。
Q10:交易广播超时后最安全的处理是什么?
答: 使用广播前本地计算的 Transaction Hash 查询多个节点,必要时重播完全相同 Raw Transaction,不能直接创建新 Nonce 交易。
延伸知识
- React 集成:Wallet/Chain/Account State、Query Cache、Block Invalidation 与 Pending Transaction。
- 交易状态机:Draft、Simulation、Signature、Pending、Replacement、Reorg 与 Finality。
- UX 与安全:可读交易、Approval、ENS、钓鱼提示和签名预览。
- 事件索引:Logs、Block Range、Removed Log、Checkpoint 与 Reorg Recovery。
- RPC 安全:错误节点、隐私泄露、供应商信任和链数据交叉验证。