EVM Trackers Standalone
The trackers of @tuwaio/pulsar-evm also work without the Pulsar store. Use them when you:
- keep transactions in your own state (Redux, MobX, another Zustand store, a database);
- track transactions on a server, where there is no
localStorageand no wallet; - need a callback for each stage of the lifecycle instead of store updates.
Without the store nothing is persisted and nothing is resumed after a restart: you decide what to save and when to start tracking again. The trackers use only viem, @wagmi/core and HTTP APIs (the Safe tracker of @tuwaio/pulsar-evm/safe only viem and HTTP), so they run in the browser and in Node.js.
Why evmTracker Instead of waitForTransactionReceipt
| Situation | waitForTransactionReceipt (viem) | evmTracker (Pulsar) |
|---|---|---|
| The node has not indexed the tx | Waits for the receipt; the transaction details are not fetched. | Retries getTransaction (10 times, 3 s apart by default) and passes the details (nonce, fees, to) to a callback. |
| Only the wallet’s RPC holds the tx | Finds nothing by its hash. | Asks the wallet connected on the chain through its provider, with no prompt, and goes on with the nonce it answers: a speed-up of such a transaction is seen too. |
| Transient RPC errors | The call rejects once its timeout or its own retries are exhausted. | Any error while waiting means no answer yet: waits again up to receiptAttempts times with growing pauses (6 by default, 435 s), then calls onFailure. |
| Speed-up or cancel in the wallet | Reported through onReplaced, if the replacement lands while one call runs and that call can identify the transaction. | Reads the sender’s nonce after every attempt, so it is reported at any moment, also when the node no longer returns the old hash. Calls onReplaced and stops, so the replaced transaction is never reported as successful. |
| Several confirmations | confirmations option of the single call. | Polls confirmations every 5 s and reports each count through onConfirmationsUpdate. |
evmTracker calls onSuccess for every mined transaction, including reverted ones: check receipt.status.
Standard Transactions: evmTracker
evmTracker follows a transaction by its hash through the wagmi client of its chain, and resolves when tracking has finished:
import { evmTracker } from '@tuwaio/pulsar-evm';
import { createConfig, http } from '@wagmi/core';
import { sepolia } from 'viem/chains';
const wagmiConfig = createConfig({ chains: [sepolia], transports: { [sepolia.id]: http() } });
export async function trackTransaction(hash: `0x${string}`) {
await evmTracker({
config: wagmiConfig,
tx: { txKey: hash, chainId: sepolia.id, requiredConfirmations: 2 },
onTxDetailsFetched: (details) => console.log('Nonce', details.nonce),
onConfirmationsUpdate: (confirmations) => console.log(`${confirmations}/2 confirmations`),
onSuccess: async (_details, receipt) => {
console.log(receipt.status === 'success' ? 'Confirmed' : 'Reverted', receipt.blockNumber);
},
onReplaced: (replacement) => console.log(`Replaced (${replacement.reason}) by`, replacement.transaction.hash),
onFailure: (error) => console.error('Tracking failed', error),
});
}The chain must be configured in the wagmi config. Tracking fails at once for the zero hash or an unknown chain.
ERC-4337 UserOperations: erc4337Tracker
erc4337Tracker polls eth_getUserOperationReceipt every 2 s until the UserOperation is included in a bundle, and gives up after 60 consecutive failed requests. It calls onSuccess with the hash of the bundle transaction; pass that hash to evmTracker if you also need block confirmations:
import { erc4337Tracker, evmTracker } from '@tuwaio/pulsar-evm';
import { type Config } from '@wagmi/core';
import { sepolia } from 'viem/chains';
declare const wagmiConfig: Config;
export function trackUserOperation(userOpHash: `0x${string}`, pimlicoApiKey?: string) {
erc4337Tracker({
// Uses bundlerUrl if set, else api.pimlico.io with the API key, else the rate-limited public Pimlico endpoint.
tx: { txKey: userOpHash, chainId: sepolia.id, pimlicoApiKey, pending: true },
onSuccess: ({ hash }) => {
if (!hash) return;
void evmTracker({
config: wagmiConfig,
tx: { txKey: hash, chainId: sepolia.id },
onTxDetailsFetched: () => {},
onSuccess: async (_details, receipt) => console.log('Bundle transaction', receipt.status),
onReplaced: () => {},
onFailure: (error) => console.error(error),
});
},
onFailure: (result) => console.error('UserOperation failed:', result?.reason ?? 'too many failed requests'),
});
}erc4337Tracker returns immediately; polling runs in the background. A reverted UserOperation calls onFailure with its revert reason.
EIP-5792 Call Batches: eip5792Tracker
eip5792Tracker asks the wallet that sent a batch with wallet_sendCalls for its status every 2 s, through the wagmi connector, and gives up after 60 consecutive failed requests (for example while no wallet is connected). It calls onSuccess with the hash of the transaction that executed the batch; pass that hash to evmTracker if you also need block confirmations:
import { eip5792Tracker } from '@tuwaio/pulsar-evm';
import { type Config, sendCalls } from '@wagmi/core';
import { type Address, encodeFunctionData, erc20Abi } from 'viem';
declare const wagmiConfig: Config;
export async function payInOneBatch(token: Address, to: Address, amount: bigint) {
const { id } = await sendCalls(wagmiConfig, {
calls: [{ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [to, amount] }) }],
});
eip5792Tracker({
tx: { txKey: id, pending: true },
config: wagmiConfig,
onSuccess: ({ hash }) => console.log('Executed in', hash),
onFailure: (result) => console.error('Batch failed:', result?.reason ?? 'too many failed requests'),
});
}In the store, return the batch ID from actionFunction and pass tracker: TransactionTracker.EIP5792: the store tracker writes the executing transaction’s hash and then follows it on-chain, and after a reload resumes at the stage it reached.
Safe Multisig: Polling Fetcher
Safe is tracked by polling an API. Pass its fetcher to initializePollingTracker from @tuwaio/pulsar-core, which calls it every 5 s by default (or on the schedule of a pollingInterval function) and gives up after 10 consecutive failed requests (it then calls onFailure() without arguments), or, with retryForeverEveryMs, keeps asking at that delay.
The Safe tracker is also available as @tuwaio/pulsar-evm/safe, an entry that loads neither @wagmi/core nor @tuwaio/orbit-evm: install @tuwaio/pulsar-evm, @tuwaio/pulsar-core, @tuwaio/orbit-core, viem and dayjs, and an app built on viem alone can track Safe transactions.
import { initializePollingTracker } from '@tuwaio/pulsar-core';
import { createSafeFetcher, safePollingInterval } from '@tuwaio/pulsar-evm/safe';
// Safe: txKey is the safeTxHash, from is the Safe address. Without a timeout it waits until the owners act.
export function trackSafeTransaction(safeTxHash: `0x${string}`, safeAddress: `0x${string}`, chainId: number) {
initializePollingTracker({
tx: { txKey: safeTxHash, chainId, from: safeAddress, pending: true },
fetcher: createSafeFetcher({ pendingTimeoutMs: null }),
pollingInterval: safePollingInterval, // every 5 s for the first hour, then every 30 s
onIntervalTick: (status) =>
console.log(`Confirmed by ${status.confirmations?.length ?? 0} of ${status.confirmationsRequired} owners`),
onSuccess: (status) => console.log('Executed in', status.transactionHash),
onFailure: (status) => console.error(status ? 'Execution failed' : 'Not found or too many failed requests'),
onReplaced: (executed) =>
console.warn('Another transaction with the same nonce was executed:', executed.safeTxHash),
});
}- Safe: the fetcher calls the Safe Transaction Service of the chain (see
SafeTransactionServiceUrlsfor the supported chains) and reports each check toonIntervalTick, with the owner confirmations collected (confirmations) and the threshold (confirmationsRequired). A 404 response callsonFailure()without a response and stops. A transaction still pendingpendingTimeoutMsafter it was proposed (one day forsafeFetcherand by default) callsonFailurewith its status and stops;createSafeFetcher({ pendingTimeoutMs: null })waits without a limit.
This fetcher stops with withoutRemoving: true, so a removeTxFromPool you pass to initializePollingTracker is called only when polling gives up: after maxRetries consecutive errors, or on a FinalPollingError.
Helpers
checkTransactionsTracker({ actionTxKey, connectorType, tracker })returns the tracker Pulsar would use for a key:ERC4337only when requested,Safefor Safe connectors (evm:safe),Ethereumotherwise.readNonceSlot(client, { hash, slot: { from, nonce } })tells what became of a transaction from its sender and nonce, when a node no longer returns its hash:open(the nonce is free),spender_found(another transaction spent it, and its receipt is not readable yet),ours,replaced(with viem’s replacement data) orspent. Pass the sender and nonce toevmTrackerasknownDetailsafter a restart, so it reads them too.speedUpTxActionandcancelTxActionresend a pending EIP-1559 transaction with the same nonce, 15% above the fees last sent for it and at least at the market estimate, only from the account that sent it. They need thenonceand fee fields the tracker fetched;evmTrackerthen reports the original transaction as replaced.selectEvmTxExplorerLinkbuilds the block explorer (or Safe app) link of a transaction.
import { TransactionTracker } from '@tuwaio/pulsar-core';
import { checkTransactionsTracker } from '@tuwaio/pulsar-evm';
const { tracker, txKey } = checkTransactionsTracker({ actionTxKey: '0xabc123', connectorType: 'evm:metamask' });
console.log(tracker === TransactionTracker.Ethereum, txKey); // true '0xabc123'The full list of exports is in the @tuwaio/pulsar-evm reference.