@tuwaio/pulsar-core
@tuwaio/pulsar-core is the Layer 3 (L3) core package of Pulsar, the transaction tracking project of TUWA Stage 2 (“State & Connection”, next to Satellite Connect). Built on zustand (with the persist middleware), immer and @tuwaio/orbit-core, it keeps the pool of tracked transactions, runs new transactions through chain adapters and restarts their trackers after a page reload. It has no chain logic, UI or network requests of its own: the chain adapters are @tuwaio/pulsar-evm and @tuwaio/pulsar-solana.
🏛️ Core Capabilities
- Transaction store:
createPulsarStorereturns a vanilla Zustand store. ItsexecuteTxActionvalidates the metadata, setsinitialTxfor immediate UI feedback, checks the wallet’s chain, runs thebeforeTxProcesspreflight, calls youractionFunctionto sign and submit, adds the transaction totransactionsPooland starts its tracker. - Chain adapters: pass one adapter or an array. Each transaction goes to the adapter whose
keymatches itsadapter(the first adapter when none matches). Adapters implement theTxAdaptercontract, so other chains can be added. - Persistence and resume: the pool is saved to
localStorage(see Browser Storage below). After a reload,initializeTransactionsPoolrestarts the trackers of pending transactions. The pool keeps at mostmaxTransactions(default 50) and evicts the oldest one. - Metadata safety: each
titlestring is limited to 100 characters, eachdescriptionstring to 300 and the JSON ofpayloadto 10 KB, and strings must not contain executable-like patterns (eval(,Function(,setTimeout/setIntervalwith a string,javascript:). Invalid metadata throwsPulsarTransactionValidationErrorbefore anything runs; invalid restored or remote transactions are dropped. This is a defensive gate, not a replacement for escaping output in your UI. - Remote sync:
onRemoteCreatesends every new transaction to your backend (for example Quasar) in the background, without its API keys, retries unconfirmed syncs later, and never delays or blocks tracking.injectExternalPendingTxsandcreateTxInMemoryStorebring the remote history back into the app; both drop transactions that fail validation. - Selectors and React binding:
selectAllTransactions,selectPendingTransactions,selectTxByKeyand the…ByActiveWalletvariants;createBoundedUseStoreturns the store into a typed React hook. - Tracker building blocks:
initializePollingTracker(a polling loop with consecutive-failure retries) andcreateTxUpdater(keeps a tracker’s copy of the transaction in sync with its store updates) for custom trackers.
💾 Installation
pnpm add @tuwaio/pulsar-core @tuwaio/orbit-core zustand immer dayjs[!IMPORTANT]
@tuwaio/orbit-core(>=0.3),zustand(5.x),immer(11.x) anddayjs(1.x) are peer dependencies and must be installed alongside@tuwaio/pulsar-core. Add@tuwaio/pulsar-evmand/or@tuwaio/pulsar-solanafor the chain adapters, and@tuwaio/pulsar-reactfor React apps.
🚀 Usage
Creating the store and sending a transaction
import { OrbitAdapter } from '@tuwaio/orbit-core';
import { createPulsarStore, type EvmTransaction, TransactionStatus } from '@tuwaio/pulsar-core';
import { pulsarEvmAdapter } from '@tuwaio/pulsar-evm';
import { createConfig, http, injected, sendTransaction } from '@wagmi/core';
import { sepolia } from 'viem/chains';
const wagmiConfig = createConfig({
chains: [sepolia],
connectors: [injected()],
transports: { [sepolia.id]: http() },
});
export const pulsarStore = createPulsarStore<EvmTransaction>({
name: 'pulsar-transactions', // localStorage key
adapter: pulsarEvmAdapter(wagmiConfig, [sepolia]),
});
// Once per page load, on the client: resume the transactions that were pending before a reload.
void pulsarStore.getState().initializeTransactionsPool();
export async function sendTip(to: `0x${string}`) {
await pulsarStore.getState().executeTxAction({
actionFunction: () => sendTransaction(wagmiConfig, { to, value: 1_000_000_000_000_000n }),
params: {
adapter: OrbitAdapter.EVM,
desiredChainID: sepolia.id,
type: 'tip',
title: ['Sending tip', 'Tip sent', 'Tip failed', 'Tip replaced'],
},
onSuccess: (tx) => console.log(tx.txKey, tx.status === TransactionStatus.Success),
});
}
// Read the state anywhere; `pulsarStore.subscribe` notifies you about changes.
pulsarStore.subscribe((state) => {
const pending = Object.values(state.transactionsPool).filter((tx) => tx.pending);
console.log(`${pending.length} pending transaction(s)`);
});executeTxAction rejects when the metadata is invalid, the wallet is on another chain and does not switch, beforeTxProcess throws (unless abortOnTxError: false) or the wallet rejects the transaction; initialTx.error holds the normalized error. For standard EVM transactions it resolves only when tracking has finished, so drive the UI from the store instead of awaiting it.
The full React setup, with a wallet connector, typed transactions and the Solana variant, is on the Getting Started page.
Preflight checks
beforeTxProcess runs after the chain check and before the wallet is asked to sign. Throw to block the transaction; a beforeTxProcess passed to executeTxAction replaces the global one for that transaction:
import { OrbitAdapter } from '@tuwaio/orbit-core';
import { createPulsarStore, type EvmTransaction } from '@tuwaio/pulsar-core';
import { pulsarEvmAdapter } from '@tuwaio/pulsar-evm';
import { type Config } from '@wagmi/core';
import { sepolia } from 'viem/chains';
declare const wagmiConfig: Config;
declare function sendSwap(): Promise<`0x${string}`>;
const pulsarStore = createPulsarStore<EvmTransaction>({
name: 'pulsar-transactions',
adapter: pulsarEvmAdapter(wagmiConfig, [sepolia]),
beforeTxProcess: () => {
if (!navigator.onLine) throw new Error('You are offline.');
},
});
await pulsarStore.getState().executeTxAction({
actionFunction: sendSwap,
beforeTxProcess: async () => {
const response = await fetch('/api/swaps/enabled');
if (!response.ok) throw new Error('Swaps are paused.');
},
params: { adapter: OrbitAdapter.EVM, desiredChainID: sepolia.id, type: 'swap', title: 'Swap' },
});With abortOnTxError: false (globally or per call), a beforeTxProcess error is logged and the transaction continues. abortOnTxError does not apply to onRemoteCreate.
Remote sync and recovery
Pass onRemoteCreate to send every new transaction to your backend, for example a server action that forwards it to Quasar . Pulsar keeps working when the backend is slow or down:
addTxToPoolwrites the transaction to the pool (and tolocalStorage) first, withsyncStatus: 'pending-sync'and its key inunsyncedTxKeys, and the tracker starts right away.onRemoteCreateruns in the background: when it resolves, the transaction becomes'synced'and the key is removed; when it rejects, the error is logged and the key stays. Reject (throw) on failure: a resolved promise counts as synced.- The trackers keep following the transaction in the browser, so its status stays correct without the backend. A slow backend never delays tracking, and a sync interrupted by a closed tab is still listed after the reload.
reconcileUnsyncedTransactionscallsonRemoteCreateagain for every unsynced transaction that is not already being sent: at the start of everyexecuteTxAction, when an unsynced transaction reaches a terminal status, and whencreateTxInMemoryStoreloads the first history page. Successful ones become'synced'; failed ones stay listed, also across reloads.
onRemoteCreate receives a copy of the transaction without pimlicoApiKey and gelatoApiKey: configure provider keys on the backend instead (for Quasar, in the app settings). bundlerUrl is sent, so do not put API keys in it.
To show the remote history, createTxInMemoryStore merges the pages returned by your getHistory with the local pool (terminal transactions are never overwritten by stale data, and transactions that fail validation are skipped), and injectExternalPendingTxs adds pending transactions from other devices to the local pool and tracks them. The complete Next.js + Quasar integration, with server actions and SIWX sessions, is in the TUWA SDK documentation .
🗄️ Browser Storage
createPulsarStore saves its state with Zustand’s persist middleware, by default in localStorage. Pass another storage (and other persist options such as partialize or version) in the same config object to change it:
| Key | Written by | Content |
|---|---|---|
The name you pass (unique) | createPulsarStore | { state, version } where state is transactionsPool (every tracked transaction), lastAddedTxKey and unsyncedTxKeys (keys not yet confirmed by onRemoteCreate) |
- The state is written on every change: when a transaction is added, updated by its tracker or removed.
initialTx, the transaction being signed, is neither saved nor restored, so a reload during signing leaves no stale signing state (pass your ownpartializeandmergeto change that). - In the browser the saved state is restored synchronously when the store is created, so the first client render already has the pool. Server rendering starts with an empty pool: render transaction lists on the client only. Where
localStorageis unavailable, nothing is read or written. - Nothing expires on its own. Transactions leave the pool only through the
maxTransactionseviction orremoveTxFromPool: the built-in trackers keep failed transactions asFailed.store.persist.clearStorage()deletes the saved state. - Everything in a transaction is saved, including
payloadand, for ERC-4337,bundlerUrlandpimlicoApiKey(needed to resume tracking after a reload; a Pimlico key used in the browser is public anyway). Do not put secrets inpayload. createTxInMemoryStorekeeps its state in memory only.
📚 API Reference
Every export, with signatures and types generated from the source, is documented at pulsar.docs.tuwa.io/packages/pulsar-core .
📄 License
Licensed under the Apache-2.0 License. See the LICENSE file for details.
Enumerations
Classes
Interfaces
Type Aliases
- ActionTxKey
- BaseTransaction
- BeforeTxProcess
- CheckTxTracker
- EvmTransaction
- ExtractState
- InitialTransaction
- InitialTransactionParams
- ITxInMemoryStore
- ITxInMemoryStoreParameters
- ITxTrackingStore
- PollingFetcherParams
- PollingTrackerConfig
- PulsarAdapter
- SolanaTransaction
- StarknetTransaction
- StoreSlice
- Transaction
- TransactionPool
- TxAdapter
- TxInMemoryPagination
- UpdatableTransactionFields
Variables
- createBoundedUseStore
- MAX_TRANSACTION_DESCRIPTION_LENGTH
- MAX_TRANSACTION_PAYLOAD_BYTES
- MAX_TRANSACTION_TITLE_LENGTH