Skip to Content
GuidesSync Transactions to Quasar

Sync Transactions to Quasar

This guide continues the Full-Stack React guide: every transaction a user sends is also sent to Quasar through your server. Quasar tracks it on the server until its final status (so the result is known even when the tab is closed), sends your webhooks, and returns the history of the wallet on any device. At the end you have:

  • two Server Actions that call Quasar with the secret key, after checking that the signed-in wallet is the one the request is about;
  • a Pulsar store that checks the sign-in before each transaction and syncs each new transaction;
  • the history of the wallet, loaded from Quasar page by page, in the Nova Connect and Nova Transactions modals.

The nextjs-tuwa-quasar starter template is a complete app built the same way.

Tip

The step that differs by network has an EVM and a Solana tab, linked to the other guides.


Before You Start

  • The app of the Full-Stack React guide, with the SIWX routes and sessionStore of Step 3. The browser never sees the Quasar key: it calls your Server Actions, and they check the SIWX session cookie.
  • A Quasar app and its secret key (Apps & Keys), in QUASAR_SECRET_KEY on the server. In Quasar Cloud, also add your RPC provider to the app: Quasar Cloud tracks transactions only through it. For a self-hosted node, put the URL of its API in NEXT_PUBLIC_QUASAR_BASE_URL; without it, the code uses Quasar Cloud.

Step 1: Install the Quasar Client

pnpm add @tuwaio/quasar-sdk

Its peer dependency @tuwaio/pulsar-core comes with @tuwaio/sdk.


Step 2: Add the Server Actions

Both actions read the SIWX session from the cookie and refuse to act for another wallet: without this check, any visitor could spend the quota of your app or read the history of any address.

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, }); }

isSessionMatchingTarget compares EVM addresses case-insensitively and Solana addresses exactly. appName keeps the history of this app apart when several apps share one Quasar app.


Step 3: Sync From the Transaction Store

Replace the store of the full-stack guide. beforeTxProcess runs preFlightTxCheck before the wallet is asked to sign: it stops the transaction when the user is not signed in or Quasar does not respond. onRemoteCreate sends each new transaction to the Server Action; throw when the action fails, so Pulsar keeps the transaction marked as unsynced and sends it again later (reconcileUnsyncedTransactions). The history store keeps the pages loaded from Quasar together with the local transactions.

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);

injectExternalPendingTxs tracks here the pending transactions sent from another device, and marks a transaction that this device still shows as pending with the final status from the history. Both fire the “transaction finished” event of the store once their final status reaches this device, so a useTxFinished listener (React to Finished Transactions) refreshes the data of this device too.

preFlightTxCheck reads the SIWX session that Nova Connect keeps in @tuwaio/sdk/siwx; it is a check for the UI, the Server Action still verifies the cookie.

Batches of Calls (EIP-5792)

A batch sent with wallet_sendCalls (tracker: TransactionTracker.EIP5792) is synced by its batch ID, which only the wallet can resolve. Once Pulsar learns the hash of the transaction that executed the batch, send it to Quasar: Quasar then tracks the batch on-chain and sends its webhook. Without a hash, Quasar marks the batch Failed one hour after it was synced. This needs @tuwaio/quasar-sdk 0.6 or later.

Add a third Server Action. It reads the batch from Quasar to check that the signed-in wallet sent it:

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.' }; } }

Then watch the store for batch hashes, next to the store of the EVM tab. watchBatchHashes reports each batch once, after it is synced; when the action fails, it tries again on the next change of the 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); });

Step 4: Show the History

Feed Nova from the history store instead of the local pool, pass its pagination, and load the first page once the connected wallet is signed in. Only the parts that change from Step 5 of the full-stack guide are new: HistoryLoader, pagination and useHistoryStore.

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> ); }

Send a transaction, open the app in another browser with the same wallet, and sign in: the transaction is in the history there too. It also appears in the Quasar dashboard under the app.


Server-Signed Transactions (Withdrawals & Payouts)

When your server signs and broadcasts a transaction itself — such as a withdrawal from a server wallet, an automated payout or a backend relayer — no browser Pulsar store or localStorage is involved. Call quasar.pulsar.syncCreate directly from the backend after broadcasting: syncing is idempotent by txKey (retrying or rebroadcasting the same signed transaction returns duplicate: true), and Quasar delivers a signed webhook once the transaction reaches its terminal state (Finality, Expiry and Reconciliation).

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', ); }

📚 Next Steps

  • Receive a signed request on your backend when a transaction succeeds, fails or is replaced — also on localhost: Webhooks.
  • Understand what the syncs cost and what happens when the quota is used up: Quotas & Limits.
  • Run Quasar on your own servers: Self-Hosting.
  • Look up every export of the client: @tuwaio/quasar-sdk, and the endpoints in the API Reference.
Last updated on