为什么 Web3 交易状态管理充满缺陷(以及为什么 useState 是反模式)
绝大多数 Web3 前端将链上交易视同普通 HTTP POST 请求:调用 RPC,在组件的 useState 中设置加载状态,并祈祷用户在交易确认前不要离开当前页面。
// ⚠️ THE FATAL ANTI-PATTERN: Component-bound transaction stateexport function SwapButton() { const [isLoading, setIsLoading] = useState(false); const [txHash, setTxHash] = useState<string | null>(null); const handleDeposit = async () => { try { setIsLoading(true); // Step 1: Wallet signature & broadcast const hash = await walletClient.sendTransaction({ ... }); setTxHash(hash); // Step 2: Awaiting on-chain receipt in component memory await publicClient.waitForTransactionReceipt({ hash }); toast.success('Transaction confirmed!'); } catch (error) { console.error('Transaction failed:', error); toast.error('Transaction reverted'); } finally { setIsLoading(false); } }; return ( <button onClick={handleDeposit} disabled={isLoading}> {isLoading ? 'Processing...' : 'Deposit Funds'} </button> );}这种模式存在根本性设计缺陷,在真实网络拥堵、矿工费激增或加速交易时会立即崩溃。
1. 分布式网络的状态失配
在传统 Web2 客户端-服务器架构中,请求遵循同步的二元生命周期:
Pending ──► Resolved (200 OK) or Rejected (500 Error)区块链交易并非简单的请求响应,而是分布式的多阶段状态跃迁:
2. useState 的四大致命生产级故障
故障 1:组件卸载导致追踪丢失
页面跳转销毁组件实例与未决 Promise,造成状态丢失与重复扣款。
故障 2:加速与替换交易陷阱
用户在钱包中点击 Speed Up,以相同 Nonce 发送更高 Gas 的新交易,产生全新的交易哈希:
Hash(T2) ≠ Hash(T1) (Account Nonce N is identical)而在简单的组件代码中,开发者仅等待初始哈希的收据:
// Naive component handler:const receipt = await publicClient.waitForTransactionReceipt({ hash: H1 });组件仍苦等旧 Hash 陷入永久加载。Pulsar 在跟踪器中解决这一问题:在两次获取收据之间读取发送方的 nonce,即使节点已忘记旧哈希也能发现替换。旧记录变为 Replaced,并带有 replacedTxHash 和 replacedReason。加速后 Pulsar 添加新哈希的记录并跟踪到最终结果;取消则到此结束。
故障 3:多标签页与刷新健忘症
React 内存状态随刷新灰飞烟灭,多标签页无法共享交易进度。
故障 4:软确认与区块高度失效
初次出块不代表最终确认,Solana 等高速链或 Rollup 必须校验至 finalized 或处理超时判定:
| 阶段 | Solana 状态 | EVM Rollup 状态 | 是否足以判定成功? |
|---|---|---|---|
| 乐观阶段 | processed | L2 排序器收据 (Sequencer Receipt) | ⚠️ 存在重组可能 |
| 共识安全 | confirmed | L1 批次已提交 (Batch Submission) | ✅ 极高可信度 |
| 不可篡改 | finalized | L1 最终确认状态 (Finalized) | 🔒 数学上完全不可逆 |
3. 架构解法:本地优先的状态机
┌─────────────────────────────────────────────────────────────┐│ Blockchain Transports Layer ││ Viem (EVM) • @solana/kit (Solana) • Bundlers (ERC-4337) │└──────────────────────────────┬──────────────────────────────┘ │ (Dispatched Actions) ▼┌──────────────────────────────┴──────────────────────────────┐│ Headless Engine (@tuwaio/pulsar-core) ││ • Local-first Indexed/LocalStorage Persistence (Immer) ││ • Nonce-aware Replacement Detection (sender nonce) ││ • Auto-reconnection & Polling Resumption after Reload │└──────────────────────────────┬──────────────────────────────┘ │ (Passive Subscriptions) ▼┌──────────────────────────────┴──────────────────────────────┐│ React View Layer (Passive) ││ Subscribes to selectors: usePulsarStore(...) │└─────────────────────────────────────────────────────────────┘4. TUWA 生态实操落地
步骤 1:定义强类型业务交易联合类型
// src/types/transactions.tsimport type { EvmTransaction, SolanaTransaction } from '@tuwaio/sdk/pulsar'; export enum AppTxType { TOKEN_SWAP = 'TOKEN_SWAP', STAKE_COLLATERAL = 'STAKE_COLLATERAL',} export type TokenSwapTx = EvmTransaction & { type: AppTxType.TOKEN_SWAP; payload: { tokenIn: string; tokenOut: string; amount: string; };}; export type TransactionUnion = TokenSwapTx | SolanaTransaction;步骤 2:初始化持久化 Pulsar Store
实例化包含跨链适配器与 Local-First 持久化能力的无头状态机:
// src/stores/pulsarStore.ts'use client'; import { pulsarEvmAdapter } from '@tuwaio/evm-sdk/pulsar';import { preFlightTxCheck } from '@tuwaio/quasar-sdk/react';import { createBoundedUseStore, createPulsarStore } from '@tuwaio/sdk/pulsar';import { pulsarSolanaAdapter } from '@tuwaio/solana-sdk/pulsar'; import { syncTransaction } from '@/app/actions';import { wagmiConfig, appChains, solanaRPCUrls } from '@/configs/web3';import { QUASAR_BASE_URL } from '@/constants';import type { TransactionUnion } from '@/types/transactions'; const initialStore = createPulsarStore<TransactionUnion>({ name: 'tuwa-transactions-storage', adapter: [pulsarEvmAdapter(wagmiConfig, appChains), pulsarSolanaAdapter({ rpcUrls: solanaRPCUrls })], // Pre-flight: verify the SIWX session and that the Quasar engine (Cloud or self-hosted) is reachable beforeTxProcess: async () => { await preFlightTxCheck(QUASAR_BASE_URL); }, // Background sync: delegate to a Server Action that verifies SIWX session // server-side and forwards the transaction to Quasar Engine via Secret Key // Throw on failure: Pulsar then keeps the transaction marked as unsynced and sends it again later onRemoteCreate: async (tx) => { const result = await syncTransaction(tx); if (!result.success) throw new Error(result.reason ?? 'Remote sync failed'); },}); export const usePulsarStore = createBoundedUseStore(initialStore);在服务端 Server Actions 中完成 SIWX 会话校验与 Quasar 引擎转发:
// src/app/actions.ts'use server'; import { Quasar, Transaction } from '@tuwaio/quasar-sdk';import { getSiwxServerSession, isSessionMatchingTarget } from '@tuwaio/sdk/siwx/server';import { cookies } from 'next/headers'; import { QUASAR_BASE_URL } from '@/constants'; const quasar = new Quasar({ baseUrl: QUASAR_BASE_URL, secretKey: process.env.QUASAR_SECRET_KEY ?? '',}); export async function syncTransaction(tx: Transaction) { const session = await getSiwxServerSession({ cookieSource: await cookies(), cookieName: 'siwx-session', signingSecret: process.env.SIWX_SIGNING_SECRET!, }); if (!session) { return { success: false, reason: 'unauthenticated' }; } // Verify the SIWX session wallet matches the transaction sender if (tx.from && !isSessionMatchingTarget(session, tx.from, tx.chainId)) { return { success: false, reason: 'session_mismatch' }; } await quasar.pulsar.syncCreate(tx, 'my-dapp'); return { success: true };}步骤 3:通过 executeTxAction 派发写操作
将具体的合约写操作抽象为纯粹的 @wagmi/core 动作函数:
// src/transactions/evm/swapTokens.tsimport { writeContract } from '@wagmi/core';import { parseUnits } from 'viem';import { wagmiConfig } from '@/configs/wagmi';import { ROUTER_ABI, ROUTER_ADDRESS } from '@/contracts/router'; export async function swapTokens({ tokenIn, tokenOut, amount, recipient,}: { tokenIn: `0x${string}`; tokenOut: `0x${string}`; amount: string; recipient: `0x${string}`;}) { return writeContract(wagmiConfig, { abi: ROUTER_ABI, address: ROUTER_ADDRESS, functionName: 'exactInputSingle', args: [ { tokenIn, tokenOut, fee: 3000, recipient, amountIn: parseUnits(amount, 6), amountOutMinimum: 0n, sqrtPriceLimitX96: 0n, }, ], chainId: 1, });}// src/components/SwapActionButton.tsx'use client'; import { usePulsarStore } from '@/stores/pulsarStore';import { OrbitAdapter } from '@tuwaio/sdk/orbit';import { AppTxType } from '@/types/transactions';import { swapTokens } from '@/transactions/evm/swapTokens'; export function SwapActionButton({ amount, tokenIn, tokenOut, recipient,}: { amount: string; tokenIn: `0x${string}`; tokenOut: `0x${string}`; recipient: `0x${string}`;}) { const executeTxAction = usePulsarStore((state) => state.executeTxAction); const handleSwap = async () => { await executeTxAction({ actionFunction: () => swapTokens({ tokenIn, tokenOut, amount, recipient }), params: { type: AppTxType.TOKEN_SWAP, adapter: OrbitAdapter.EVM, desiredChainID: 1, // Automatic chain validation & switching title: ['Swapping Tokens', 'Swap Confirmed', 'Swap Failed', 'Transaction Sped Up'], description: [ `Swapping ${amount} USDC for ETH...`, `Successfully swapped ${amount} USDC for ETH.`, 'Transaction reverted on-chain.', 'Gas fee was increased; replacement hash emitted via callback.', ], payload: { tokenIn, tokenOut, amount, }, withTrackedModal: true, // Triggers Nova UIKit modal sheet requiredConfirmations: 2, // Wait for safe consensus depth }, onSuccess: (tx) => { console.log('[Pulsar] Transaction finalized:', tx.txKey); }, onReplaced: (newTx, oldTx) => { // Pulsar flags oldTx as Replaced and, after a speed-up, already follows the new record: this is only a notice console.warn(`[Pulsar] Transaction ${oldTx.txKey} was replaced by: ${newTx.replacedTxHash}`); }, onError: (err, tx) => { console.error('[Pulsar] Execution error:', err); }, }); }; return ( <button onClick={handleSwap} className="btn-primary"> Execute Swap </button> );}步骤 4:页面重载时恢复活跃轮询池
// src/app/providers.tsx'use client'; import { useEffect } from 'react';import { usePulsarStore } from '@/stores/pulsarStore'; export function TransactionTrackerBootstrap({ children }: { children: React.ReactNode }) { const initializeTransactionsPool = usePulsarStore((s) => s.initializeTransactionsPool); useEffect(() => { // Automatically re-attaches trackers to all unconfirmed mempool transactions initializeTransactionsPool(); }, [initializeTransactionsPool]); return <>{children}</>;}步骤 5:基于选择器的响应式 UI 订阅
// src/components/PendingTransactionsBadge.tsximport { usePulsarStore } from '@/stores/pulsarStore';import { selectPendingTransactions } from '@tuwaio/sdk/pulsar'; export function PendingTransactionsBadge() { const pendingTransactions = usePulsarStore((state) => selectPendingTransactions(state.transactionsPool)); if (pendingTransactions.length === 0) return null; return ( <div className="flex items-center gap-2 px-3 py-1 bg-amber-500/10 text-amber-500 rounded-full border border-amber-500/20 text-xs font-mono"> <span className="w-2 h-2 rounded-full bg-amber-500 animate-ping" /> <span>{pendingTransactions.length} pending on-chain</span> </div> );}步骤 6:具备抗刷新能力的状态副作用
// src/components/BalanceRefresher.tsx'use client'; import { TransactionStatus, useTxFinished } from '@tuwaio/sdk/pulsar'; import { usePulsarStore } from '@/stores/pulsarStore';import { AppTxType } from '@/types/transactions'; export function BalanceRefresher({ refetchBalances }: { refetchBalances: () => void }) { const subscribeTxFinished = usePulsarStore((state) => state.subscribeTxFinished); // Also runs for a swap whose tracker was restarted after a reload useTxFinished({ subscribeTxFinished, onFinished: (tx) => { if (tx.type === AppTxType.TOKEN_SWAP && tx.status === TransactionStatus.Success) refetchBalances(); }, }); return null;}5. 架构对比:useState 与 Pulsar
| 场景 | 简单 useState | TUWA Pulsar |
|---|---|---|
| 路由跳转 | ❌ 组件销毁导致 Promise 丢失 | ✅ 独立于 DOM 常驻全局 Store |
| 加速与取消交易 | ❌ 停留在旧 Hash 挂起或报错 | ✅ 读取发送方的 nonce,标记 Replaced 及原因,并将加速交易跟踪到最终结果 |
| 浏览器页面刷新 | ❌ 内存状态直接归零至 idle | ✅ 挂载时自动恢复持久化池追踪 |
| 刷新后的回调响应 | ❌ 原生链式 Promise 彻底丢失不执行 | ✅ onTxFinished 与 useTxFinished 在页面重载后依然保证每笔完成交易执行一次 |
| 多标签页一致性 | ❌ 标签页彼此完全孤立脱节 | ✅ 通过 storage events 跨标签页秒级同步 |
| 多链兼容 (EVM + Solana) | ❌ 各自为政的面条式适配 Hook | ✅ 统一抽象的适配器接口与类型系统 |
| Re-Org 链重组与回滚 | ❌ 抛出未捕获的原始 RPC 异常 | ✅ 结构化 TransactionStatus 与标准化错误模型 |
| 软确认机制 (Solana) | ❌ 单一布尔值:过早确认或长时间傻等 | ✅ confirmationStatus 呈报 confirmed,终态于 finalized 标记 Success |
| 丢单判定 (Solana) | ❌ 永久处于 pending 状态直至超时 | ✅ 超越 lastValidBlockHeight 立即准确判定过期失败 |
总结
彻底将交易生命周期从 React 视图层剥离,是构建抗脆弱 Web3 应用的核心分水岭。
📚 后续指引
- React 交易跟踪指南:一步步接入 Pulsar。
最后更新于