@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) by evicting the oldest finished transactions; a transaction still pending is never evicted, so the pool can hold more while many are pending. A finished transaction is droppedretentionDays(default 30) after it finished. - Final results: once a transaction has
Success,FailedorReplaced, a later write cannot change its result, so a tracker that only lost sight of it cannot turn a mined transaction into a failed one. Every transaction gets one tracker, however ofteninitializeTransactionsPoolruns, and tabs merge what the others have saved, transaction by transaction. A finished transaction stays in the pool. After a speed-up the old record stays asReplacedand a new record under the new hash, linked byreplaces, goes on to its own result with the same callbacks.removeTxFromPooldeletes a transaction and stops its tracker. - 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. - Transaction finished event: the
onTxFinishedoption ofcreatePulsarStoreand the store’ssubscribeTxFinishedare called when a transaction reachesSuccess,FailedorReplaced, and again if the chain corrects aFailed, per open tab, so key anything with side effects ontxKeyandstatus, whatever finished it: its tracker, a tracker restarted after a page reload (which no longer has theonSuccess,onErrorandonReplacedofexecuteTxAction), or remote sync. Use them for reactions that must also happen after a reload, such as refetching balances; in React,useTxFinishedof@tuwaio/pulsar-react. - 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; itspollingIntervalcan be a function of the time since polling started, to poll less often over time, and withretryForeverEveryMsa service that cannot be reached never ends tracking) 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.4),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. It resolves once the tracker has started, not when the transaction has finished, so drive the UI from the store.
The full React setup, with a wallet connector, typed transactions and the Solana variant, is in the React transaction tracking guide .
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.
Reacting to finished transactions
The callbacks of executeTxAction belong to one page session: functions cannot be saved to localStorage, so a transaction still pending at a reload finishes without them. Put reactions that must always happen in onTxFinished, which is part of the store’s code and runs for every transaction, also after a reload. It runs synchronously while the store records the final status, before the callbacks of the transaction; its return value is not awaited, and an error it throws or a promise it rejects is logged without stopping tracking.
import { createPulsarStore, type Transaction, TransactionStatus, type TxAdapter } from '@tuwaio/pulsar-core';
declare const adapter: TxAdapter<Transaction>; // pulsarEvmAdapter(...) or pulsarSolanaAdapter(...)
declare function refreshBalances(): void;
export const pulsarStore = createPulsarStore<Transaction>({
name: 'pulsar-transactions',
adapter,
onTxFinished: (tx) => {
if (tx.status === TransactionStatus.Success) refreshBalances();
},
});
// Or add a listener later, for example from a component; it returns the function that removes it
const unsubscribe = pulsarStore.getState().subscribeTxFinished((tx) => console.log(tx.txKey, tx.status));
unsubscribe();A transaction that was already final when the store restored it from storage does not call them.
Which one to use. Both kinds of callbacks stay; they answer different questions:
onTxFinished, subscribeTxFinished, useTxFinished | onSuccess, onError, onReplaced of executeTxAction | |
|---|---|---|
| Runs for | Every transaction of the store, per open tab, whatever finished it | The one transaction of that call |
| After a page reload | Yes | No: the page that started the transaction is gone |
| Receives | The transaction with its final status (and error when it failed) | The transaction; onError also the raw error, onReplaced also the transaction as tracking started |
| Use it for | Data that must stay correct: refetching balances or lists, analytics, telling your backend | The interface of the current page: closing a dialog, showing a message in place, navigating to a success page |
Decide what to do from the transaction itself, not from a function attached to it: switch on tx.type (your own label, such as 'SWAP' or 'APPROVE') and read what you need from tx.payload. One listener can serve any number of transaction kinds, and any number of listeners can be added. For the same transaction, the store’s listeners run first, then the callbacks of the call.
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 rpcUrl (an endpoint can hold a key): 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 (pass isTxRemoved of the store, so that a transaction a person stopped tracking stays out) merges the pages returned by your getHistory with the local pool (a final result is never overwritten by stale data, except a Failed that the chain answers otherwise, a server’s Failed does not replace a transaction still pending, 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 Quasar transaction sync guide . A person who stops tracking a transaction removes it from both stores: removeTxFromPool of each, or the in-memory one keeps it pending for good, as nothing is left to finish it.
🗄️ 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. - A finished transaction (including a
Failedone) is droppedretentionDaysafter itsfinishedTimestamp(or itslocalTimestampwhen it has none), when the saved state is restored and when a transaction is added; the default is 30 days, andretentionDays: nullkeeps finished transactions without a term. Pending transactions stay until they finish. Transactions also leave the pool through themaxTransactionseviction andremoveTxFromPool.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.${name}:removedTxKeysholds the keys of the latest 50 transactions removed withremoveTxFromPool, as when a person stops tracking one, so that a page load, a server’s history or another tab does not bring them back. It is its own key: every tab saves the whole state, and a tab with an older copy would undo the removal. Sending the same key anew clears it.
📚 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
- TxFinishedListener
- TxInMemoryPagination
- UpdatableTransactionFields
Variables
- createBoundedUseStore
- MAX_TRANSACTION_DESCRIPTION_LENGTH
- MAX_TRANSACTION_PAYLOAD_BYTES
- MAX_TRANSACTION_TITLE_LENGTH
Functions
- createAbandonCheck
- createPulsarStore
- createTxInMemoryStore
- createTxUpdater
- initializePollingTracker
- initializeTxTrackingStore
- selectAllTransactions
- selectAllTransactionsByActiveWallet
- selectPendingTransactions
- selectPendingTransactionsByActiveWallet
- selectTxByKey
- validateInitialTransactionParams
- validateTransaction