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:
| Chain | sendUserOperation + poll | sendUserOperationSync | Faster by |
|---|---|---|---|
| Base | 697 ms | 364 ms | 333 ms (48%) |
| BSC | 728 ms | 294 ms | 434 ms (60%) |
| Robinhood | 258 ms | 182 ms | 76 ms (29%) |
It is almost as fast as sending the same call from an EOA:
| Chain | sendUserOperationSync | EOA send + poll |
|---|---|---|
| Base | 364 ms | 255 ms |
| BSC | 294 ms | 340 ms |
| Robinhood | 182 ms | 100 ms |
The EOA sends with eth_sendRawTransaction, then polls eth_getTransactionReceipt.
For how these were measured, see the sendUserOperationSync reference.
Prerequisites
- A Pimlico API key with instant bundling enabled. Contact us or email support@pimlico.io to enable it.
- An app on Base, BSC or Robinhood.
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
kindispending, the user operation may have been sent. Wait for its receipt and don't resend. - If
kindisbundle_reverted, another bundler may have included the user operation first. Check for its receipt, and resend only if there is none. - If
kindisnot_found, nothing was sent. Send the same calls again. - If
kindisrejected, the bundler rejected the user operation before sending it. Fix the cause inerror.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
AA25right 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 });