One can use ERC-4337 user operations to execute cross-chain transactions. There are three possible interactions that one can have:

  • Relaying only on the origin chain
  • Relaying only on the destination chain
  • Relaying on both the origin and destination chain

Relaying only on the origin chain

Interacting with the solver using ERC-4337 user operations requires two additional steps on top of the standard /quote flow.

  • The first requires sending an extra field in the /quote request body, userOperationGasOverhead. This field indicates how much additional gas overhead will be necessary to include the user operation on the chain as compared to a normal EOA transaction.

  • The second step is optional, you can choose to send the user operation to the Solver’s eth_sendUserOperation JSON RPC endpoint or submit it to any Bundler of your choice. Once the user operation has been included on the chain either by the Solver or by another Bundler on the origin chain, the Solver will execute the destination chain transaction.

One slightly different thing in the way the Solver accepts user operations is that the maxFeePerGas and maxPriorityFeePerGas should be set to 0. This is done to make sure that the solver does not get paid twice (once by the funds being moved in the call data and second by the on-chain compensation to the Bundler by the Entry Point). This also requires you to use a paymaster as the entry point logic does not allow such a user operation if a smart account has to pay for the operation.

If using an external Bundler, you might need to send non-zero maxFeePerGas and maxPriorityFeePerGas as two different parties are being paid in this case.

In the following example, we demonstrate how to use a Safe Smart Account with a Pimlico Paymaster and use Relay to get the quotes and submit the user operation.

The script makes use of Viem’s privateKeyToAccount to create an owner but any other owner creation method is applicable.


import { createPublicClient, http, toHex } from "viem";
import { baseSepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import {
  createBundlerClient,
  entryPoint07Address,
  UserOperation,
} from "viem/account-abstraction";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { toSafeSmartAccount } from "permissionless/accounts";
import axios from "axios";

const executeUserOperation = async () => {
  try {
    const owner = privateKeyToAccount("<PRIVATE_KEY>");

    const publicClient = createPublicClient({
      chain: baseSepolia,
      transport: http("https://sepolia.base.org"),
    });

    const paymasterClient = createPimlicoClient({
      transport: http("<PIMLICO_RPC_URL>"),
      entryPoint: {
        address: entryPoint07Address,
        version: "0.7",
      },
    });

    const safeAccount = await toSafeSmartAccount({
      client: publicClient,
      entryPoint: {
        address: entryPoint07Address,
        version: "0.7",
      },
      owners: [owner],
      version: "1.4.1",
    });

    const bundlerClient = createBundlerClient({
      account: safeAccount,
      client: publicClient,
      paymaster: paymasterClient,
      transport: http("<PIMLICO_RPC_URL>"),
    });

    // Bridging ETH from Base Sepolia to ETH on Sepolia
    // userOperationGasOverhead set to 300000
    const requestData = {
      user: safeAccount!.address,
      originChainId: 84532,
      destinationChainId: 11155111,
      originCurrency: "0x0000000000000000000000000000000000000000",
      destinationCurrency: "0x0000000000000000000000000000000000000000",
      recipient: owner.address,
      tradeType: "EXACT_INPUT",
      amount: "20000000000000000",
      referrer: "relay.link/swap",
      useExternalLiquidity: false,
      userOperationGasOverhead: 300000,
    };

    const quoteResponse = await axios.post(
      "https://api.relay.link/quote",
      requestData,
      {
        headers: {
          "Content-Type": "application/json",
        },
      },
    );
	
		
		// Fetch the transaction data from the response of the quote call 
    const { steps } = quoteResponse.data;
    const item = steps[0].items[0];
    const { to, data, value } = item.data;

    const callData = await safeAccount.encodeCalls([
      {
        to: to as `0x${string}`,
        data,
        value,
      },
    ]);

    const { callGasLimit, verificationGasLimit, preVerificationGas } =
      await bundlerClient.estimateUserOperationGas({
        account: safeAccount,
        calls: [
          {
            to,
            value,
            data,
          },
        ],
      });

    const userOperation = {
      sender: safeAccount.address,
      nonce: await safeAccount.getNonce(),
      callData,
      callGasLimit,
      maxFeePerGas: 0n,
      maxPriorityFeePerGas: 0n,
      preVerificationGas,
      verificationGasLimit,
      paymasterPostOpGasLimit: 100_000n, // can be estimated specific to a paymaster
      paymasterVerificationGasLimit: 200_000n, // can be estimated specific to a paymaster
      signature: await safeAccount.getStubSignature(),
    };

    const { paymaster, paymasterData } =
      await paymasterClient.getPaymasterData({
        ...userOperation,
        chainId: baseSepolia.id,
        entryPointAddress: entryPoint07Address,
      });

    (userOperation as UserOperation).paymaster = paymaster;
    (userOperation as UserOperation).paymasterData = paymasterData;
    
    

    userOperation.signature =
      await safeAccount.signUserOperation(userOperation);
	
		// Convert all values to hex
    const hexUserOperation = Object.fromEntries(
      Object.entries(userOperation).map(([key, value]) => {
        if (typeof value === "number" || typeof value === "bigint") {
          return [key, toHex(value)];
        }
        return [key, value];
      }),
    );

    const eth_sendUserOperationResponse = await axios.post(
      "https://api.relay.link/execute/user-op/84532",
      {
        jsonrpc: "2.0",
        id: 1,
        method: "eth_sendUserOperation",
        params: [hexUserOperation, entryPoint07Address],
      },
      {
        headers: {
          "Content-Type": "application/json",
        },
      },
    );

    const { result } = eth_sendUserOperationResponse.data;

    const transactionHash = await bundlerClient.waitForUserOperationReceipt({
      hash: result,
    });

    console.log("transactionHash", transactionHash);
  } catch (error) {
    console.error("Error:", error);
  }
};

executeUserOperation();

Relaying only on the destination chain

To execute user operations on the destinations, you can pass in the transaction details in the txs array while calling the quote API.

import { encodeFunctionData } from 'viem';

// Sample user operation
// The maxFeePerGas and maxPriorityFeePerGas should be "0x0" as the Solver is already compensated for its destination chain actions
const userOperation = {
  "sender": "0xcD7cc356852c8db2C587f53409491396ac966ac7",
  "nonce": "0x193f25819da0000000000000000",
  "callData": "0x541d63c80000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000008a95a0e55d591b00cf18253148647daad25fa6a8000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000",
  "callGasLimit": "0xf4240",
  "maxFeePerGas": "0x0",
  "maxPriorityFeePerGas": "0x0",
  "preVerificationGas": "0xf4240",
  "verificationGasLimit": "0xf4240",
  "paymasterPostOpGasLimit": "0x186a0",
  "paymasterVerificationGasLimit": "0xf4240",
  "signature": "0x000000000000000000000000cf4ccb124579f0a9591d7a5e3df30e97b8fc08dd298bc9e8fdc476f1af7d756a4662f8b0ec251acffea2a24df6c13932874002644a626397749029b14097c0381c",
  "paymaster": "0x0000000000000039cd5e8aE05257CE51C473ddd1",
  "paymasterData": "0x00000067690ea8000000000000a51b902711bddd24df60863c9d8731db288186ecd72328ed9c7c2f5c6c3b3cb9366ab6328a28035ece11751680e217944682e3ea0d013919d9a086b1c3039c4e1c"
}

const beneficiary = "0x0000000000000000000000000000000000000000"; // this address is just for completion, no funds would be transfer here as maxFee values are set to 0

// Transaction object to be executed by the solver on destination chain
const transaction = {
	to: "0x0000000071727De22E5E9d8BAf0edAc6f37da032" // ERC-4337 Entry Point address
	value: "0" // Value transfer will always be 0 as Solver to Entry Point
	data: encodeFunctionData({
	  abi: ENTRY_POINT_ABI, // https://etherscan.io/address/0x0000000071727De22E5E9d8BAf0edAc6f37da032#code
	  functionName: 'handleOps',
	  args: [userOperation, beneficiary]
	})
};

// txs array to be passed in the quote API call
// One can add more transactions pre and post user operation execution
const txs = [transaction]; 

Once the txs array has been created, pass it in the quote API call.

Relaying on both the origin and destination chain

The ability to execute user operations on both the origin and destination chains can be done by individually following the above-mentioned steps.

  • Pass the userOperationGasOverhead while making the /quote call and also pass the destination chain Entry Point transaction encoded in the txs array.
  • Create the user operation using the transaction data returned in the response of the quote API.
  • Execute the origin chain user operation either by making a call to eth_sendUserOperation JSON RPC to the Solver or to any other Bundler.
  • After the user operation is included on the origin chain, the Solver will pick up the transaction and destination txs will be executed by the Solver