How I Built Gasless EVM Transactions with ZeroDev as Sponsor
· 8 min read
On this page
Gas is one of the first pieces of blockchain terminology a new user has to learn. It is also one of the easiest ways to lose that user.
In a conventional EVM transaction, the wallet must hold the chain's native token before it can do anything useful. A user who only has USDC still needs ETH on Ethereum, Base, or Arbitrum to move it. That is technically reasonable, but it is a poor onboarding experience for a payments product.
I ran into this while building an EVM send and convert flow in a React Native application. My solution was to use a ZeroDev Kernel smart account and a sponsoring paymaster. The user still signs the action, but our application pays the network fee.
This post walks through the implementation, including native transfers, ERC-20 transfers, approval-and-swap batching, and the lifecycle details that matter in a production mobile app.
"Gasless" does not mean the transaction consumes no gas. It means the user does not pay that gas. A paymaster sponsors it according to a policy and a budget you configure.
The architecture
The implementation is built on ERC-4337 account
abstraction. Instead of broadcasting
a normal transaction directly from an externally owned account, the app sends
a UserOperation:
User signs an action
↓
Kernel smart account encodes one or more calls
↓
ZeroDev paymaster agrees to sponsor the operation
↓
Bundler submits it through the ERC-4337 EntryPoint
↓
The calls execute on the EVM chainEach component has a distinct job:
- Kernel smart account: owns the assets and executes the calls.
- Paymaster: agrees to pay the network fee when the operation matches its sponsorship policy.
- Bundler: packages UserOperations into an on-chain transaction.
- EntryPoint: validates and executes the operation on-chain.
ZeroDev provides the smart-account and ERC-4337 infrastructure. In this app, Dynamic's ZeroDev integration connects the authenticated wallet to the Kernel client, while viem handles units, addresses, and ABI encoding.
Prerequisites
Before writing the transaction code, I configured:
- A ZeroDev project for every supported chain.
- A gas-sponsorship policy for each project.
- The corresponding bundler and paymaster RPC URLs.
- MPC Wallet setup, I'm using Dynamic's EVM Wallet and ZeroDev extensions in the application.
A sponsorship policy is not optional. ZeroDev requires one before it will sponsor operations, and it is also the main protection against draining the application's gas budget. In production, restrict the chains, contract targets, function selectors, per-operation cost, and rate limits as tightly as the product allows.
I keep the network-specific endpoints behind one helper:
type ZeroDevConfig = {
bundlerRpc: string;
paymasterRpc: string;
};
export function getZeroDevConfig(chainId: number): ZeroDevConfig {
const config = ZERO_DEV_BY_CHAIN[chainId];
if (!config) {
throw new Error(`ZeroDev is not configured for chain ${chainId}`);
}
return config;
}The important part is failing early on an unsupported chain. Accidentally using the RPC for a different network usually surfaces later as a confusing simulation or bundler error.
Create a sponsored Kernel client
For every operation, I resolve the endpoints for the selected chain and ask Dynamic to create a Kernel client with ZeroDev sponsorship enabled:
const { bundlerRpc, paymasterRpc } = getZeroDevConfig(chainId);
const kernelClient = await dynamicClient.zeroDev.createKernelClient({
bundlerRpc,
chainId,
paymaster: "SPONSOR",
paymasterRpc,
wallet: primaryWallet,
});The exact constructor changes between Dynamic and ZeroDev SDK generations, but
the required pieces stay the same: a signer or wallet, the chain, a bundler,
and paymaster sponsorship. If you are not using Dynamic, ZeroDev's current
sponsoring gas guide
shows the equivalent createKernelAccountClient and
createZeroDevPaymasterClient setup.
Creating the client does not guarantee that every action will be sponsored. The paymaster can still reject a UserOperation because it violates a policy, exceeds a limit, targets an unsupported chain, or cannot be simulated.
Use one call format for every transaction
Kernel executes a list of calls. Each call has the same shape:
type Call = {
to: Address;
value: bigint;
data: `0x${string}`;
};This makes native transfers, token transfers, application fees, approvals, and swaps composable. Once the calls are assembled, the submission code is the same:
const callData = await kernelClient.account.encodeCalls(calls);
const userOpHash = await kernelClient.sendUserOperation({ callData });
const userOpReceipt =
await kernelClient.waitForUserOperationReceipt({ hash: userOpHash });
if (userOpReceipt.receipt.status === "reverted") {
throw new Error("UserOperation reverted");
}
return {
userOpHash,
txHash: userOpReceipt.receipt.transactionHash,
actualGasCost: userOpReceipt.actualGasCost?.toString(),
};There are two hashes here, and they are not interchangeable. The bundler first returns a UserOperation hash. After inclusion, the receipt contains the normal transaction hash that explorers and most backend transaction tables expect.
actualGasCost is the sponsored network-gas cost in the chain's smallest
native unit. It is useful for accounting, but it is not a token amount and it
is not the same as gasUsed.
Sponsored native-token transfers
A native transfer has no contract calldata. It sends value to the recipient
with data: "0x":
export async function sendNativeToken(
kernelClient: KernelClient,
params: {
recipient: Address;
amount: bigint;
platformFee?: bigint;
treasuryAddress?: Address;
},
) {
const calls: Call[] = [
{
to: params.recipient,
value: params.amount,
data: "0x",
},
];
if (params.platformFee && params.treasuryAddress) {
calls.push({
to: params.treasuryAddress,
value: params.platformFee,
data: "0x",
});
}
return sendCalls(kernelClient, calls);
}The second call is an optional platform fee, not the network gas fee. The
paymaster sponsors the gas, while the smart account must still own enough of
the native token to cover amount + platformFee.
Because Kernel batches the calls atomically by default, the recipient transfer and treasury transfer either both succeed or both revert. ZeroDev documents this behavior in its transaction batching guide.
Convert human-readable input before building the call:
const amount = parseUnits(amountInput, tokenDecimals);Never use JavaScript floating-point numbers for on-chain amounts.
Sponsored ERC-20 transfers
An ERC-20 transfer sends no native value. Instead, it calls transfer on the
token contract:
import { encodeFunctionData, erc20Abi } from "viem";
export async function sendErc20Token(
kernelClient: KernelClient,
params: {
tokenAddress: Address;
recipient: Address;
amount: bigint;
platformFee?: bigint;
treasuryAddress?: Address;
},
) {
const transfer = (recipient: Address, amount: bigint): Call => ({
to: params.tokenAddress,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [recipient, amount],
}),
});
const calls: Call[] = [transfer(params.recipient, params.amount)];
if (params.platformFee && params.treasuryAddress) {
calls.push(transfer(params.treasuryAddress, params.platformFee));
}
return sendCalls(kernelClient, calls);
}The user can send an ERC-20 without holding the chain's native gas token. They still need enough of the ERC-20 to cover the transfer and any platform fee.
Validate the recipient and token addresses before encoding the operation. I accept both all-lowercase/all-uppercase addresses and correctly checksummed mixed-case addresses:
export function isEvmAddress(address: string): boolean {
const normalized = address.trim().replace(/^0X/, "0x");
const hex = normalized.slice(2);
const singleCase = hex === hex.toLowerCase() || hex === hex.toUpperCase();
return isAddress(normalized, { strict: !singleCase });
}Batch approval and swap into one UserOperation
The same call model works for swaps and bridges. My convert flow requests a route from LI.FI, resolves the transaction request for its next step, and sends that calldata from the Kernel account.
An ERC-20 swap normally needs two user actions:
- Approve the route's spender.
- Call the swap contract.
With a smart account, both calls can live in one atomic UserOperation.
First, read the current allowance on the source chain:
const allowance = await sourcePublicClient.readContract({
abi: erc20Abi,
address: tokenAddress,
functionName: "allowance",
args: [kernelClient.account.address, spenderAddress],
});If it is insufficient, add an approval call before the swap call:
const calls: Call[] = [];
if (allowance < requiredAmount) {
calls.push({
to: tokenAddress,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [spenderAddress, maxUint256],
}),
});
}
calls.push({
to: swapTx.to as Address,
value: swapTx.value ? BigInt(swapTx.value) : 0n,
data: swapTx.data as `0x${string}`,
});Then encode and send the entire batch once. The user signs once, the paymaster sponsors one UserOperation, and a failed swap also rolls back the approval.
LI.FI's /advanced/stepTransaction
endpoint
returns a route step with its transactionRequest populated. Follow the
request shape documented for the API or SDK version you use.
A gas-price gotcha
In this integration, the advanced swap path occasionally produced
maxFeePerGas and maxPriorityFeePerGas as zero. The paymaster simulation then
rejected the operation. Fetching the bundler's UserOperation gas price before
sending fixed it:
const callData = await kernelClient.account.encodeCalls(calls);
const gasPrice = await kernelClient.getUserOperationGasPrice();
const userOpHash = await kernelClient.sendUserOperation({
callData,
maxFeePerGas: gasPrice.maxFeePerGas,
maxPriorityFeePerGas: gasPrice.maxPriorityFeePerGas,
});This is integration- and version-dependent, so do not add it blindly. Inspect the failed UserOperation first. The ERC-4337 specification requires fee fields high enough for the bundler to include the operation, even when a paymaster is paying them.
Keep execution outside the screen lifecycle
The first version of this flow lived inside a React screen. That created two mobile-specific failure modes:
- navigating away could tear down the component that owned the async chain;
- mutation callbacks were not a reliable place to finish bookkeeping after unmount.
I moved transaction execution into a module-level function and wrote progress to a global Jotai store. The function creates an in-flight record, starts the async work, and immediately returns its local ID:
export function executeSend(params: SendParams, deps: SendDeps): string {
const inFlightId = `send-${Date.now()}`;
store.set(inFlightTransactionsAtom, (items) => [
{ id: inFlightId, status: "executing", ...toDisplayRow(params) },
...items,
]);
void runSend(params, deps, inFlightId);
return inFlightId;
}The screen only observes global state. Navigation no longer owns the transaction promise.
Module scope survives a screen unmount, but it does not survive the OS killing the app. For that, persist enough information to reconcile the operation after restart. Ideally, save the UserOperation hash as soon as the bundler accepts it, then replace it with the transaction hash after inclusion.
Treat broadcast and backend confirmation separately
An on-chain success and a backend API success are two different events. My flow marks a transaction as broadcast as soon as the UserOperation receipt is available, then asks the backend to register it.
If backend confirmation fails, the on-chain transaction must not be presented as failed. Keep the pending confirmation and retry it later:
savePendingConfirmation(confirmPayload);
try {
const confirmed = await api.confirmSend(confirmPayload);
clearPendingConfirmation(); // clear only after success
removeInFlightRow(inFlightId);
refreshTransactionList();
return confirmed;
} catch (error) {
logger.warn("Backend confirmation failed; leaving it queued for retry");
// Do not clear the persisted payload here.
}Avoid clearing the pending record in a finally block. Doing so also clears it
after an API failure, which silently disables crash recovery.
Production checks I would not skip
The happy path is short. Most of the real work is around its boundaries:
- Check sponsorship before signing. If your SDK exposes a capability check, use it to decide whether to offer a user-paid fallback or show a clear error.
- Constrain the paymaster policy. Never sponsor arbitrary targets and calldata from an untrusted client.
- Validate third-party transaction data. For swaps, verify the source chain, target contract, approval spender, token, amount, and function selector before the smart account executes returned calldata.
- Use the source chain for allowance reads. A bridge's destination chain is irrelevant to the source token approval.
- Handle every route step.
route.steps[0]is only correct if the quote is intentionally limited to one executable step. Multi-step routes need sequential execution and status tracking. - Distinguish source confirmation from bridge completion. A confirmed source transaction does not mean the assets have arrived on the destination chain.
- Check receipt status. Receiving a transaction hash does not prove the execution succeeded.
- Make backend confirmation idempotent. Retrying the same transaction hash must not create duplicate records or charges.
- Do not log secrets. Treat wallet credentials and server-side API keys as secrets, and protect public RPC identifiers with strict policies and quotas.
Final result
The user experience is now close to a conventional payment app: choose an asset, enter a recipient, sign once, and wait for confirmation. A user can move an ERC-20 without first acquiring native gas tokens, and an approval plus swap can execute as one atomic action.
The core implementation is only three operations:
- Build one or more EVM calls.
- Encode them through the Kernel smart account.
- Send a sponsored UserOperation and wait for its receipt.
What makes the feature production-ready is everything around those operations: sponsorship policies, calldata validation, lifecycle-independent execution, clear status transitions, and durable reconciliation. Account abstraction hides gas from the user, but it should not hide failure modes from the engineer.