Skip to Content
ПосібникиЧому стан Web3-транзакцій зламаний

Чому керування станом Web3-транзакцій зламане (і чому useState — це антипатерн)

Більшість Web3-фронтендів ставляться до операцій із блокчейном так само, як до звичайних HTTP-запитів POST: вони викликають RPC, вішають спіннер завантаження на локальний useState React-компонента і сподіваються, що користувач нікуди не перейде, доки мережа не поверне хеш транзакції.

// ⚠️ 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> );}

Цей підхід є хибним у самій своїй основі. Він миттєво ламається, як тільки стикається з реальною механікою розподілених мереж: перевантаженням мемпулу, стрибками комісій за газ, скиданням нонсів, прискоренням транзакцій усередині гаманця (Speed-Up / Replace), зміною вкладок або маршрутів додатку.


1. Невідповідність розподіленого стану

У класичній клієнт-серверній архітектурі Web2 запит має синхронний, бінарний життєвий цикл:

Pending ──► Resolved (200 OK) or Rejected (500 Error)

Транзакція в блокчейні — це не HTTP-запит. Це асинхронний, багатофазний перехід стану у децентралізованій одноранговій (P2P) мережі:


2. Чотири продакшн-збої useState

Збій 1: Розмонтування компонента

Якщо користувач ініціював транзакцію і перейшов на іншу сторінку додатку, компонент із useState розмонтовується. Проміс waitForTransactionReceipt втрачається, інтерфейс «забуває» про транзакцію, а користувач повторно тисне кнопку та двічі списує кошти.

Збій 2: Пастка заміни в мемпулі (пришвидшення та скасування)

Користувач натискає «Speed Up» у MetaMask, надсилаючи нову транзакцію T2 із тим самим нонсом N і вищим пріоритетом, що генерує абсолютно інший хеш:

Hash(T2) ≠ Hash(T1) (Account Nonce N is identical)

У наївному коді компонента розробники просто очікують квитанцію початкового хешу:

// Naive component handler:const receipt = await publicClient.waitForTransactionReceipt({ hash: H1 });

Ваш компонент продовжує очікувати старий хеш, якого більше ніколи не буде включено в блок, і назавжди зависає в нескінченному завантаженні. Pulsar вирішує це в трекері: між спробами отримати квитанцію він читає nonce відправника, тож бачить заміну навіть тоді, коли вузол уже забув старий хеш. Старий запис стає Replaced із replacedTxHash та replacedReason. Після прискорення Pulsar додає запис нового хешу й веде його до результату, а скасування на цьому завершується.

Збій 3: Амнезія вкладок та перезавантаження

Локальний стан React ізольований у пам'яті конкретної вкладки браузера. Якщо користувач перезавантажить сторінку або відкриє додаток у кількох вкладках, стан непідтверджених транзакцій безповоротно втрачається.

Збій 4: Софт-підтвердження, фінальність та експірація блохешу

Більшість примітивних рішень вважають транзакцію завершеною одразу після першого включення в блок (block 0). На швидких мережах на кшталт Solana або L2 реорганізація блоку анулює таку транзакцію. У Solana транзакція повинна відстежуватися до статусу finalized, а у випадку відсутності підтвердження — перевірятися за lastValidBlockHeight:

ЕтапСтатус у SolanaСтатус в EVM RollupЧи безпечно вважати завершеною?
ОптимістичнийprocessedКвитанція L2 Sequencer⚠️ Можливий re-org
Безпечний консенсусconfirmedПублікація батчу в L1✅ Висока впевненість
НезміннийfinalizedФіналізований стан L1🔒 Математично незворотно

3. Рішення: відокремлені local-first стейт-машини

Для побудови стійких Web3-фронтендів відстеження транзакцій ніколи не повинно жити в React-компонентах. Воно має працювати в автономній безголовій (headless) стейт-машині поза деревом DOM:

┌─────────────────────────────────────────────────────────────┐│ 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(...) │└─────────────────────────────────────────────────────────────┘

Чотири фундаментальні правила надійної архітектури транзакцій:

  • Персистентне сховище як єдине джерело правди: серіалізація в localStorage перед очікуванням мережі.
  • Відстеження заміни за нонсом: трекер читає nonce відправника і веде прискорену транзакцію до результату новим записом.
  • Автоматичне відновлення пулу: підхоплення незавершених транзакцій після перезавантаження сторінки.
  • Пасивні селектори UI: компоненти лише підписуються на зрізи стану без керування промісами.

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

Створіть екземпляр headless-сховища з мультичейн-адаптерами та 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 Action для верифікації SIWX сесії та викликів Quasar SDK на бекенді:

// 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

СценарійНативний useStateTUWA Pulsar
Зміна сторінки❌ Проміс знищується при розмонтуванні✅ Живе незалежно від DOM у центральному сховищі
Прискорення / Скасування❌ Зависає або викидає помилку на новому хеші✅ Читає nonce відправника, фіксує Replaced із причиною і веде прискорення до результату
Перезавантаження браузера❌ Стан очікування втрачається; скидається до idle✅ Автоматично продовжує відстеження пулу при монтуванні
Реакція після оновлення❌ Код ланцюжка промісу більше не виконується✅ onTxFinished та useTxFinished спрацьовують один раз для кожної завершеної транзакції навіть після перезавантаження
Синхронізація вкладок❌ Вкладки повністю десинхронізовані✅ Миттєва синхронізація через storage events між усіма вкладками
Мультичейн (EVM + Solana)❌ Спагеті з кастомних несумісних хуків✅ Єдиний інтерфейс адаптерів для EVM та кластерів Solana
Обробка Re-Org та Revert❌ Викидає сиру неформатовану помилку в компонент✅ Структурований TransactionStatus із нормалізованим об'єктом помилки
Софт-підтвердження (Solana)❌ Один boolean: завершено на confirmed або довге очікування finalized✅ confirmationStatus сигналізує confirmed, доки транзакція очікує; Success на finalized
Втрачена транзакція (Solana)❌ Зависає в pending до довільного таймауту✅ Фіксує помилку після експірації blockhash (lastValidBlockHeight) з причиною в error

Висновки

Спроба трактувати ончейн-транзакції як прості асинхронні проміси породжує критичні баги в продакшні. Відокремлення виконання від дерева React, персистентне local-first збереження та активний моніторинг мемпулу забезпечують детерміновану надійність додатків на базі екосистеми TUWA.


📚 Наступні кроки

Востаннє оновлено