同步交易至 Quasar(云端或自托管节点)
本指南介绍如何将 Quasar 交易索引层无缝接入 Next.js 应用,实现跨设备历史记录同步与后台持久化跟踪。
准备工作
- 已具备全栈 React 架构与 SIWX 认证会话支持;
- 在服务端安全环境变量中配置
QUASAR_SECRET_KEY。
步骤 1:安装 Quasar 客户端 SDK
pnpm add @tuwaio/quasar-sdk步骤 2:添加安全 Server Actions
src/app/quasarActions.ts
'use server'; import { Quasar, QuasarSDKError, type Transaction } from '@tuwaio/quasar-sdk';import { getSiwxServerSession, isSessionMatchingTarget } from '@tuwaio/sdk/siwx/server';import { cookies } from 'next/headers'; import { sessionStore } from '@/lib/authStores'; const quasar = new Quasar({ secretKey: process.env.QUASAR_SECRET_KEY ?? '', baseUrl: process.env.NEXT_PUBLIC_QUASAR_BASE_URL,}); const APP_NAME = 'my-app'; async function getSession() { return getSiwxServerSession({ cookieSource: await cookies(), sessionStore });} export async function syncTransaction(tx: Transaction): Promise<{ success: boolean; error?: string }> { const session = await getSession(); if (!session || !isSessionMatchingTarget(session, tx.from, tx.chainId)) { return { success: false, error: 'The signed-in wallet did not send this transaction.' }; } try { await quasar.pulsar.syncCreate(tx, APP_NAME); return { success: true }; } catch (error) { return { success: false, error: error instanceof QuasarSDKError ? error.message : 'Quasar is unavailable.' }; }} export async function getHistory(params: { walletAddress: string; page?: number }) { const session = await getSession(); if (!session || !isSessionMatchingTarget(session, params.walletAddress)) { return null; } return quasar.pulsar.getHistory({ walletAddress: params.walletAddress, page: params.page, limit: 10, appName: APP_NAME, });}步骤 3:自 Pulsar 交易状态机发起同步
src/hooks/pulsarStore.ts
import { pulsarEvmAdapter } from '@tuwaio/evm-sdk/pulsar';import { preFlightTxCheck } from '@tuwaio/quasar-sdk/react';import { createBoundedUseStore, createPulsarStore, createTxInMemoryStore, type Transaction } from '@tuwaio/sdk/pulsar'; import { getHistory, syncTransaction } from '@/app/quasarActions';import { appChains, wagmiConfig } from '@/configs/appConfig'; export const pulsarStore = createPulsarStore<Transaction>({ name: 'my-app-transactions', adapter: [pulsarEvmAdapter(wagmiConfig, appChains)], beforeTxProcess: () => preFlightTxCheck(process.env.NEXT_PUBLIC_QUASAR_BASE_URL), onRemoteCreate: async (tx) => { const result = await syncTransaction(tx); if (!result.success) throw new Error(result.error); },}); export const usePulsarStore = createBoundedUseStore(pulsarStore); export const historyStore = createTxInMemoryStore<Transaction>({ localTransactionsPool: pulsarStore.getState().transactionsPool, reconcileUnsyncedTransactions: pulsarStore.getState().reconcileUnsyncedTransactions, getHistory: ({ page, walletAddress }) => getHistory({ walletAddress, page }), // Pending transactions sent from another device continue to be tracked here. onHistoryFetched: (remoteTxs) => pulsarStore.getState().injectExternalPendingTxs(remoteTxs),}); pulsarStore.subscribe((state) => historyStore.getState().syncWithLocalPool(state.transactionsPool)); export const useHistoryStore = createBoundedUseStore(historyStore);批量调用(EIP-5792)
通过 wallet_sendCalls 发送的批量调用(tracker: TransactionTracker.EIP5792)按批次 ID 同步,而该 ID 只有钱包能够解析。一旦 Pulsar 获知执行该批次的交易哈希,就把它发送给 Quasar:Quasar 随后在链上跟踪该批次并发送其 Webhook。若一直没有哈希,Quasar 会在同步一小时后将该批次标记为 Failed。需要 @tuwaio/quasar-sdk 0.6 或更高版本。
添加第三个 Server Action。它会从 Quasar 读取该批次,以确认是当前登录的钱包发送了它:
src/app/quasarActions.ts
export async function syncBatchHash(txKey: string, hash: `0x${string}`): Promise<{ success: boolean; error?: string }> { const session = await getSession(); const [batch] = session ? (await quasar.pulsar.getHistory({ txKey, appName: APP_NAME, limit: 1 })).docs : []; if (!session || !batch || !isSessionMatchingTarget(session, batch.from, batch.chainId)) { return { success: false, error: 'The signed-in wallet did not send this batch.' }; } try { await quasar.pulsar.syncHash(txKey, hash); return { success: true }; } catch (error) { return { success: false, error: error instanceof QuasarSDKError ? error.message : 'Quasar is unavailable.' }; }}然后在 EVM 标签页的 store 旁边监听批次哈希。watchBatchHashes 会在每个批次同步后只报告一次;若操作失败,会在 store 下一次变化时重试:
src/hooks/pulsarStore.ts
import { watchBatchHashes } from '@tuwaio/quasar-sdk/pulsar'; import { syncBatchHash } from '@/app/quasarActions'; watchBatchHashes(pulsarStore, async (tx) => { const result = await syncBatchHash(tx.txKey, tx.hash); if (!result.success) throw new Error(result.error);});步骤 4:渲染多端一致的交易历史面板
将 Nova 的数据源切换为 Quasar 历史存储库,配置分页并在签名认证通过后加载首屏历史:
src/providers/Providers.tsx
'use client'; import { EVMConnectorsWatcher } from '@tuwaio/evm-sdk/nova-connect';import { satelliteEVMAdapter } from '@tuwaio/evm-sdk/satellite';import { NovaConnectProvider, type NovaConnectProviderProps } from '@tuwaio/sdk/nova-connect';import { NovaTransactionsProvider } from '@tuwaio/sdk/nova-transactions/providers';import { getAdapterFromConnectorType } from '@tuwaio/sdk/orbit';import { type TxInMemoryPagination, useInitializeTransactionsPool } from '@tuwaio/sdk/pulsar';import { SatelliteConnectProvider, useSatelliteConnectStore } from '@tuwaio/sdk/satellite';import { isSessionMatchingTarget, useSiwxSessionStore } from '@tuwaio/sdk/siwx';import { type ReactNode, useEffect } from 'react'; import { appChains, wagmiConfig } from '@/configs/appConfig';import { useHistoryStore, usePulsarStore } from '@/hooks/pulsarStore'; const satelliteAdapter = satelliteEVMAdapter(wagmiConfig, appChains); const siwx: NovaConnectProviderProps['siwx'] = { getNonce: async () => { const res = await fetch('/api/siwx/nonce'); return ((await res.json()) as { nonce: string }).nonce; }, verifier: async (payload) => { const res = await fetch('/api/siwx/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); return res.ok ? res.json() : null; }, destroyer: async () => { await fetch('/api/siwx/logout', { method: 'POST' }); },}; // Loads the first page of the history when the connected wallet is signed in.function HistoryLoader() { const address = useSatelliteConnectStore((state) => state.activeConnection?.address); const session = useSiwxSessionStore((state) => state.session); const fetchInitial = useHistoryStore((state) => state.fetchInitial); const isSignedIn = Boolean(session && address && isSessionMatchingTarget(session, address)); useEffect(() => { if (isSignedIn && address) void fetchInitial(address); }, [isSignedIn, address, fetchInitial]); return null;} function useHistoryPagination(): TxInMemoryPagination { const isLoading = useHistoryStore((state) => state.isLoading); const isError = useHistoryStore((state) => state.isError); const currentPage = useHistoryStore((state) => state.currentPage); const hasMore = useHistoryStore((state) => state.hasMore); const fetchNextPage = useHistoryStore((state) => state.fetchNextPage); return { isLoading, isError, currentPage, hasMore, fetchNextPage };} function TransactionsUI() { const transactionsPool = useHistoryStore((state) => state.transactionsPool); const pagination = useHistoryPagination(); const initialTx = usePulsarStore((state) => state.initialTx); const closeTxTrackedModal = usePulsarStore((state) => state.closeTxTrackedModal); const executeTxAction = usePulsarStore((state) => state.executeTxAction); const initializeTransactionsPool = usePulsarStore((state) => state.initializeTransactionsPool); const getAdapter = usePulsarStore((state) => state.getAdapter); const activeConnection = useSatelliteConnectStore((state) => state.activeConnection); useInitializeTransactionsPool({ initializeTransactionsPool }); return ( <NovaTransactionsProvider transactionsPool={transactionsPool} pagination={pagination} initialTx={initialTx} closeTxTrackedModal={closeTxTrackedModal} executeTxAction={executeTxAction} connectedWalletAddress={activeConnection?.isConnected ? activeConnection.address : undefined} connectedAdapterType={getAdapterFromConnectorType(activeConnection?.connectorType ?? 'evm:')} adapter={getAdapter()} /> );} export function Providers({ children }: { children: ReactNode }) { const transactionsPool = useHistoryStore((state) => state.transactionsPool); const pagination = useHistoryPagination(); const getAdapter = usePulsarStore((state) => state.getAdapter); return ( <SatelliteConnectProvider adapter={satelliteAdapter} autoConnect> <EVMConnectorsWatcher wagmiConfig={wagmiConfig} /> <HistoryLoader /> <TransactionsUI /> <NovaConnectProvider appChains={appChains} transactionPool={transactionsPool} pagination={pagination} pulsarAdapter={getAdapter()} siwx={siwx} withBalance withChain > {children} </NovaConnectProvider> </SatelliteConnectProvider> );}在任一设备提交交易后,于另一台设备的同一钱包登入即可立即查阅同步的完整历史。
服务端签署交易(出金与提现批量处理)
针对服务端出金打款或后端中继,直接在 Node 环境调用 quasar.pulsar.syncCreate,交易到达终态后将推送签名 Webhook:
src/server/syncServerPayout.ts
import { Quasar } from '@tuwaio/quasar-sdk';import { TransactionTracker } from '@tuwaio/quasar-sdk/pulsar';import { OrbitAdapter } from '@tuwaio/sdk/orbit'; const quasar = new Quasar({ secretKey: process.env.QUASAR_SECRET_KEY ?? '', baseUrl: process.env.NEXT_PUBLIC_QUASAR_BASE_URL,}); export async function syncServerPayout(params: { hash: `0x${string}`; chainId: number; from: `0x${string}`; to: `0x${string}`; operationId: string;}) { return quasar.pulsar.syncCreate( { adapter: OrbitAdapter.EVM, tracker: TransactionTracker.Ethereum, txKey: params.hash, hash: params.hash, chainId: params.chainId, from: params.from, to: params.to, connectorType: 'evm:server', type: 'WITHDRAWAL', title: 'Withdrawal', pending: true, localTimestamp: Math.floor(Date.now() / 1000), payload: { operationId: params.operationId }, }, 'my-app', );}📚 后续指引
- Webhooks 配置:在终态确认时接收 HMAC 签名通知。
- 自托管部署:完全自主运行 Quasar 社区版。
最后更新于