RathRath Finance
Use Cases

EIP-7702 with Account Abstraction

Learn how to execute ERC-4337 UserOperations through an EIP-7702 delegated EOA using Tachyon.

EIP-7702 with Account Abstraction

This guide demonstrates how to combine EIP-7702 authorization with ERC-4337 Account Abstraction to execute UserOperations through an EOA delegated to a smart account implementation.

Overview

By leveraging EIP-7702, you can temporarily delegate your EOA to an ERC-4337 compatible smart account, enabling:

  • Gas Sponsorship: Execute transactions without holding native tokens
  • Batched Operations: Multiple contract calls in a single transaction
  • Custom Validation: Advanced signature schemes and access controls
  • No Migration Required: Use smart account features without deploying a new wallet

Prerequisites

  • Node.js 18+
  • A funded EOA wallet
  • Tachyon API key
  • Basic understanding of EIP-7702 and Account Abstraction

Step-by-Step Implementation

Install Dependencies

npm install viem @rathfi/tachyon

Import Required Modules

import {
  createWalletClient,
  createPublicClient,
  http,
  encodeFunctionData,
  encodePacked,
  concat,
  pad,
  numberToHex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
import { Tachyon } from "@rathfi/tachyon";
import {
  entryPoint07Abi,
  entryPoint07Address,
  getUserOperationHash,
} from "viem/account-abstraction";

Configure Entry Point and Clients

Set up the ERC-4337 EntryPoint v0.7 configuration and create the necessary clients:

// Entry point configuration (v0.7)
const entryPoint = {
  address: entryPoint07Address as `0x${string}`,
  version: "0.7" as const,
  abi: entryPoint07Abi,
};

// Public client for reading blockchain state
const publicClient = createPublicClient({
  transport: http(),
  chain: base,
});

// Wallet client with your EOA
const walletClient = createWalletClient({
  account: privateKeyToAccount(process.env.PRIVATE_KEY! as `0x${string}`),
  transport: http(),
  chain: base,
});

// Contract addresses
const delegateContractAddress = "0xd6CEDDe84be40893d153Be9d467CD6aD37875a28"; // ERC-4337 Account implementation
const beneficiary = "0x4C16955d8A0DcB2e7826d50f4114990c787b21E7"; // Bundler beneficiary
const targetContract = "0xA7A833e6641D7901F30EaD6f27d4Ee2C9bb670a7"; // Your target contract

Create EIP-7702 Authorization (First Transaction Only)

Sign an authorization to delegate your EOA to the smart account implementation:

// Track delegation state - set to true after first successful delegation
const isAlreadyDelegated = false;

async function getAuthorizationList() {
  if (isAlreadyDelegated) {
    console.log("Skipping delegation - address already delegated");
    return undefined;
  }

  // Sign the authorization - viem handles nonce automatically
  const authorization = await walletClient.signAuthorization({
    contractAddress: delegateContractAddress,
  });

  // Format authorization for Tachyon
  const auth = {
    chainId: authorization.chainId,
    address: authorization.address,
    nonce: Number(authorization.nonce),
    r: authorization.r,
    s: authorization.s,
    v: Number(authorization.v),
    yParity: Number(authorization.yParity) as 0 | 1,
  };

  console.log("Authorization created for delegation");
  return [auth];
}

After the first transaction with authorization, your EOA will be delegated to the smart account. Subsequent transactions can skip the authorization step by setting isAlreadyDelegated to true.

Encode the Target Call Data

Prepare the function call you want to execute:

// Example: Calling a sayHello function
const sayHelloAbi = [
  {
    inputs: [{ internalType: "string", name: "message", type: "string" }],
    name: "sayHello",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
] as const;

const sayHelloCallData = encodeFunctionData({
  abi: sayHelloAbi,
  functionName: "sayHello",
  args: ["Hello from Tachyon!"],
});

Build the UserOperation Call Data

Encode the execution data for the smart account's execute function:

// Encode execution payload: target address + value + calldata
const executionPayload = encodePacked(
  ["address", "uint256", "bytes"],
  [
    targetContract as `0x${string}`,
    BigInt(0), // No ETH value
    sayHelloCallData as `0x${string}`,
  ]
);

// Encode the execute function call for the smart account
const executeAbi = [
  {
    inputs: [
      { internalType: "ExecMode", name: "execMode", type: "bytes32" },
      { internalType: "bytes", name: "executionCalldata", type: "bytes" },
    ],
    name: "execute",
    outputs: [],
    stateMutability: "payable",
    type: "function",
  },
] as const;

const userOperationCallData = encodeFunctionData({
  abi: executeAbi,
  functionName: "execute",
  args: [
    "0x0000000000000000000000000000000000000000000000000000000000000000", // Default exec mode
    executionPayload,
  ],
});

Create and Sign the UserOperation

Build the UserOperation structure and sign it:

// Get nonce from EntryPoint
const nonce = await publicClient.readContract({
  address: entryPoint.address,
  abi: entryPoint.abi,
  functionName: "getNonce",
  args: [walletClient.account.address, BigInt(0)],
  blockTag: "pending",
});

// Gas limits
const callGasLimit = BigInt(500_000);
const verificationGasLimit = BigInt(1_200_000);
const preVerificationGas = BigInt(100_000);

// Create UserOperation
const userOperation = {
  sender: walletClient.account.address,
  nonce: nonce,
  initCode: "0x" as `0x${string}`,
  callData: userOperationCallData,
  callGasLimit: callGasLimit,
  verificationGasLimit: verificationGasLimit,
  preVerificationGas: preVerificationGas,
  maxFeePerGas: BigInt(0),
  maxPriorityFeePerGas: BigInt(0),
  paymasterAndData: "0x" as `0x${string}`,
  signature: "0x" as `0x${string}`,
};

// Get UserOperation hash
const userOperationHash = getUserOperationHash({
  userOperation,
  entryPointAddress: entryPoint.address,
  entryPointVersion: entryPoint.version,
  chainId: base.id,
});

// Sign the UserOperation hash with EOA
const signature = await walletClient.signMessage({
  message: { raw: userOperationHash },
});

userOperation.signature = signature;

console.log("UserOperation Hash:", userOperationHash);
console.log("Signature:", signature);

Encode the handleOps Call

Prepare the EntryPoint handleOps function call:

const handleOpsCallData = encodeFunctionData({
  abi: entryPoint.abi,
  functionName: "handleOps",
  args: [
    [
      {
        sender: userOperation.sender,
        nonce: userOperation.nonce,
        initCode: userOperation.initCode || "0x",
        callData: userOperation.callData,
        accountGasLimits: concat([
          pad(numberToHex(userOperation.verificationGasLimit || BigInt(0)), {
            size: 16,
          }),
          pad(numberToHex(userOperation.callGasLimit || BigInt(0)), {
            size: 16,
          }),
        ]),
        preVerificationGas: userOperation.preVerificationGas,
        gasFees: concat([
          pad(numberToHex(BigInt(0)), { size: 16 }),
          pad(numberToHex(BigInt(0)), { size: 16 }),
        ]),
        paymasterAndData: userOperation.paymasterAndData || "0x",
        signature: userOperation.signature,
      },
    ],
    beneficiary as `0x${string}`,
  ],
});

Submit via Tachyon

Initialize the Tachyon SDK and submit the transaction:

// Calculate gas limit with buffer
const relayGasLimit =
  (userOperation.callGasLimit +
    userOperation.verificationGasLimit +
    userOperation.preVerificationGas) *
  BigInt(2);

// Initialize Tachyon SDK
const tachyon = new Tachyon({
  apiKey: process.env.TACHYON_API_KEY!,
});

// Get authorization list (only needed for first transaction)
const authorizationList = await getAuthorizationList();

// Submit transaction
const taskId = await tachyon.relay({
  chainId: base.id,
  to: entryPoint.address,
  callData: handleOpsCallData,
  value: "0",
  gasLimit: relayGasLimit.toString(),
  ...(authorizationList
    ? { authorizationList }
    : { transactionType: "flash-blocks" }),
});

console.log("Task ID:", taskId);

// Wait for execution
const tx = await tachyon.waitForExecutionHash(taskId, 30_000);
console.log("Transaction executed:", tx);
console.log("Called sayHello via EIP-7702 delegated address through EntryPoint");

Complete Example

Here's the full implementation combining all the steps:

import {
  createWalletClient,
  createPublicClient,
  http,
  encodeFunctionData,
  encodePacked,
  concat,
  pad,
  numberToHex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
import { Tachyon } from "@rathfi/tachyon";
import {
  entryPoint07Abi,
  entryPoint07Address,
  getUserOperationHash,
} from "viem/account-abstraction";

// Configuration
const entryPoint = {
  address: entryPoint07Address as `0x${string}`,
  version: "0.7" as const,
  abi: entryPoint07Abi,
};

const delegateContractAddress = "0xd6CEDDe84be40893d153Be9d467CD6aD37875a28";
const beneficiary = "0x4C16955d8A0DcB2e7826d50f4114990c787b21E7";
const targetContract = "0xA7A833e6641D7901F30EaD6f27d4Ee2C9bb670a7";

// Set to true after first successful delegation
const isAlreadyDelegated = false;

const publicClient = createPublicClient({
  transport: http(),
  chain: base,
});

const walletClient = createWalletClient({
  account: privateKeyToAccount(process.env.PRIVATE_KEY! as `0x${string}`),
  transport: http(),
  chain: base,
});

async function executeEIP7702WithAA() {
  // Step 1: Get authorization (only for first transaction)
  let authorizationList = undefined;

  if (!isAlreadyDelegated) {
    const authorization = await walletClient.signAuthorization({
      contractAddress: delegateContractAddress,
    });

    authorizationList = [
      {
        chainId: authorization.chainId,
        address: authorization.address,
        nonce: Number(authorization.nonce),
        r: authorization.r,
        s: authorization.s,
        v: Number(authorization.v),
        yParity: Number(authorization.yParity) as 0 | 1,
      },
    ];
    console.log("Authorization created");
  }

  // Step 2: Encode target call
  const sayHelloCallData = encodeFunctionData({
    abi: [
      {
        inputs: [{ internalType: "string", name: "message", type: "string" }],
        name: "sayHello",
        outputs: [],
        stateMutability: "nonpayable",
        type: "function",
      },
    ],
    functionName: "sayHello",
    args: ["Hello from Tachyon!"],
  });

  // Step 3: Build execution payload
  const executionPayload = encodePacked(
    ["address", "uint256", "bytes"],
    [targetContract as `0x${string}`, BigInt(0), sayHelloCallData as `0x${string}`]
  );

  const userOperationCallData = encodeFunctionData({
    abi: [
      {
        inputs: [
          { internalType: "ExecMode", name: "execMode", type: "bytes32" },
          { internalType: "bytes", name: "executionCalldata", type: "bytes" },
        ],
        name: "execute",
        outputs: [],
        stateMutability: "payable",
        type: "function",
      },
    ],
    functionName: "execute",
    args: [
      "0x0000000000000000000000000000000000000000000000000000000000000000",
      executionPayload,
    ],
  });

  // Step 4: Get nonce and create UserOperation
  const nonce = await publicClient.readContract({
    address: entryPoint.address,
    abi: entryPoint.abi,
    functionName: "getNonce",
    args: [walletClient.account.address, BigInt(0)],
    blockTag: "pending",
  });

  const userOperation = {
    sender: walletClient.account.address,
    nonce: nonce,
    initCode: "0x" as `0x${string}`,
    callData: userOperationCallData,
    callGasLimit: BigInt(500_000),
    verificationGasLimit: BigInt(1_200_000),
    preVerificationGas: BigInt(100_000),
    maxFeePerGas: BigInt(0),
    maxPriorityFeePerGas: BigInt(0),
    paymasterAndData: "0x" as `0x${string}`,
    signature: "0x" as `0x${string}`,
  };

  // Step 5: Sign UserOperation
  const userOperationHash = getUserOperationHash({
    userOperation,
    entryPointAddress: entryPoint.address,
    entryPointVersion: entryPoint.version,
    chainId: base.id,
  });

  userOperation.signature = await walletClient.signMessage({
    message: { raw: userOperationHash },
  });

  // Step 6: Encode handleOps
  const handleOpsCallData = encodeFunctionData({
    abi: entryPoint.abi,
    functionName: "handleOps",
    args: [
      [
        {
          sender: userOperation.sender,
          nonce: userOperation.nonce,
          initCode: "0x",
          callData: userOperation.callData,
          accountGasLimits: concat([
            pad(numberToHex(userOperation.verificationGasLimit), { size: 16 }),
            pad(numberToHex(userOperation.callGasLimit), { size: 16 }),
          ]),
          preVerificationGas: userOperation.preVerificationGas,
          gasFees: concat([
            pad(numberToHex(BigInt(0)), { size: 16 }),
            pad(numberToHex(BigInt(0)), { size: 16 }),
          ]),
          paymasterAndData: "0x",
          signature: userOperation.signature,
        },
      ],
      beneficiary as `0x${string}`,
    ],
  });

  // Step 7: Submit via Tachyon
  const relayGasLimit =
    (userOperation.callGasLimit +
      userOperation.verificationGasLimit +
      userOperation.preVerificationGas) *
    BigInt(2);

  const tachyon = new Tachyon({
    apiKey: process.env.TACHYON_API_KEY!,
  });

  const taskId = await tachyon.relay({
    chainId: base.id,
    to: entryPoint.address,
    callData: handleOpsCallData,
    value: "0",
    gasLimit: relayGasLimit.toString(),
    ...(authorizationList
      ? { authorizationList }
      : { transactionType: "flash-blocks" }),
  });

  console.log("Task ID:", taskId);

  const tx = await tachyon.waitForExecutionHash(taskId, 30_000);
  console.log("Transaction executed:", tx);

  return tx;
}

executeEIP7702WithAA();

Key Considerations

Security: Never expose your private key in client-side code. Use secure key management solutions in production.

  • Delegation Persistence: After the first transaction with authorizationList, your EOA remains delegated until you explicitly revoke it
  • Gas Estimation: The example uses fixed gas limits. In production, consider using gas estimation APIs for optimal values
  • Nonce Management: The EntryPoint manages its own nonce system, separate from the EOA's transaction nonce
  • Chain Support: EIP-7702 is supported on specific chains. Check supported networks for availability

On this page