Skip to content
LogoLogo

How to send user operations with the lowest latency

This guide shows you how to land a user operation as fast as Pimlico can: it goes into its own bundle right away and its receipt comes back in the same call, with no mempool wait or receipt polling. Use it when your app needs the result before it can continue, such as a payment or a trade.

How fast it is

Median time from signing until the receipt is in hand, against Pimlico's production bundler:

ChainsendUserOperation + pollsendUserOperationSyncFaster by
Base697 ms364 ms333 ms (48%)
BSC728 ms294 ms434 ms (60%)
Robinhood258 ms182 ms76 ms (29%)

It is almost as fast as sending the same call from an EOA:

ChainsendUserOperationSyncEOA send + poll
Base364 ms255 ms
BSC294 ms340 ms
Robinhood182 ms100 ms

The EOA sends with eth_sendRawTransaction, then polls eth_getTransactionReceipt.

For how these were measured, see the sendUserOperationSync reference.

Prerequisites

Steps

Create the smart account client

Create a smart account client as usual, then extend it with pimlicoActions to add sendUserOperationSync. Replace the owner with the user's signer, such as an embedded wallet or a passkey.

import { createSmartAccountClient } from "permissionless";
import { toSimpleSmartAccount } from "permissionless/accounts";
import { pimlicoActions } from "permissionless/actions/pimlico";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { createPublicClient, type Hex, http } from "viem";
import { entryPoint07Address } from "viem/account-abstraction";
import { privateKeyToAccount } from "viem/accounts";
import { base as chain } from "viem/chains"; // or bsc. For Robinhood, define the chain with viem's defineChain
 
const pimlicoUrl = `https://api.pimlico.io/v2/${chain.id}/rpc?apikey=YOUR_PIMLICO_API_KEY`;
 
const publicClient = createPublicClient({
	chain,
	transport: http(), // use your own RPC URL in production
});
 
const pimlicoClient = createPimlicoClient({
	chain,
	transport: http(pimlicoUrl),
	entryPoint: {
		address: entryPoint07Address,
		version: "0.7",
	},
});
 
const account = await toSimpleSmartAccount({
	client: publicClient,
	owner: privateKeyToAccount(process.env.PRIVATE_KEY as Hex), // the user's signer
	entryPoint: {
		address: entryPoint07Address,
		version: "0.7",
	},
});
 
const smartAccountClient = createSmartAccountClient({
	account,
	chain,
	bundlerTransport: http(pimlicoUrl),
	paymaster: pimlicoClient,
	userOperation: {
		estimateFeesPerGas: async () => {
			return (await pimlicoClient.getUserOperationGasPrice()).fast;
		},
	},
}).extend(
	pimlicoActions({
		entryPoint: {
			address: entryPoint07Address,
			version: "0.7",
		},
	}),
);

Send the user operation and check the receipt

Send the user's calls with smartAccountClient.sendUserOperationSync. It returns the receipt once the user operation is included. If success is false, the call reverted inside the account.

const receipt = await smartAccountClient.sendUserOperationSync({
	calls: [
		{
			to: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
			value: 0n,
			data: "0x1234",
		},
	],
});
 
if (!receipt.success) {
	throw new Error("The call reverted inside the account");
}
 
console.log(
	`Included: ${chain.blockExplorers.default.url}/tx/${receipt.receipt.transactionHash}`,
);

If the send throws

sendUserOperationSync never resends on its own. When it throws a SendUserOperationSyncError, decide from its kind:

  • If kind is pending, the user operation may have been sent. Wait for its receipt and don't resend.
  • If kind is bundle_reverted, another bundler may have included the user operation first. Check for its receipt, and resend only if there is none.
  • If kind is not_found, nothing was sent. Send the same calls again.
  • If kind is rejected, the bundler rejected the user operation before sending it. Fix the cause in error.cause, then send again.
import { SendUserOperationSyncError } from "permissionless/actions/pimlico";
 
const calls = [
	{
		to: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
		value: 0n,
		data: "0x1234",
	},
] as const;
 
try {
	await smartAccountClient.sendUserOperationSync({ calls });
} catch (error) {
	if (!(error instanceof SendUserOperationSyncError)) throw error;
 
	if (error.kind === "pending") {
		// May have been sent: wait for it, don't resend
		await smartAccountClient.waitForUserOperationReceipt({
			hash: error.userOperationHash,
		});
	} else if (error.kind === "bundle_reverted") {
		// Another bundler may have included it first: check before resending
		const included = await smartAccountClient
			.getUserOperationReceipt({ hash: error.userOperationHash })
			.catch(() => null);
		if (!included) await smartAccountClient.sendUserOperationSync({ calls });
	} else if (error.canResend) {
		// not_found: nothing was sent, send the calls again
		await smartAccountClient.sendUserOperationSync({ calls });
	} else {
		// rejected: fix the cause in error.cause, then send again
		throw error;
	}
}

For what each kind means, see the sendUserOperationSync reference.

Send several user operations from one account

Await each sendUserOperationSync call before sending the next one. A user operation sent while the previous one from the same account is still in flight is rejected with AA25 invalid account nonce.

  • On Base, if the next user operation is rejected with AA25 right after the previous receipt, resend it after 1–2s.
  • To send in parallel from one account, give each user operation its own nonce key. See How to send user operations in parallel.

Cut latency further with boosted user operations (optional)

If the Boosted Paymaster is enabled on your API key, drop the paymaster and set both fees to 0. This skips the gas price and paymaster calls before the send, and Pimlico bills the gas to your balance.

const boostedClient = createSmartAccountClient({
	account,
	chain,
	bundlerTransport: http(pimlicoUrl),
	// no paymaster: Pimlico bills the gas to your balance
	userOperation: {
		estimateFeesPerGas: async () => ({
			maxFeePerGas: 0n,
			maxPriorityFeePerGas: 0n,
		}),
	},
}).extend(
	pimlicoActions({
		entryPoint: {
			address: entryPoint07Address,
			version: "0.7",
		},
	}),
);
 
const boostedReceipt = await boostedClient.sendUserOperationSync({ calls });