25 min read
Overview
Ethereum’s Pectra upgrade introduced EIP-7702, enabling Externally Owned Accounts (EOAs) to temporarily adopt smart contract functionality. This bridges traditional wallets and smart contract accounts, unlocking features like batch transactions, gas sponsorship, and custom logic without changing your address.
This guide provides a step-by-step process for implementing EIP-7702 transactions using Ethers.js. You’ll learn to send batch transactions, manage sponsored transactions, revoke authorizations, and address challenges like nonce management and authorization signatures, ensuring seamless integration into your dApp.
What You Will Do
- Send EIP-7702 transactions (
0x04
type) using Ethers.js - Revoke existing delegations using Ethers.js
- Learn how delegation and authorization work
- Learn how to handle common issues like nonce management and authorization signatures
What You Will Need
- A QuickNode account with an Ethereum Sepolia endpoint
- Basic knowledge of EIP-7702
- Node.js (v20+ recommended) and TypeScript installed
- Ethers.js installed
- A Web3-compatible wallet (e.g., MetaMask) for testing - we'll use two wallets in this guide:
- First Signer: The EOA that will send transactions
- Sponsor Signer: The EOA that will sponsor gas fees for transactions
Dependency | Version |
---|---|
Node.js | >v20 |
Ethers.js | >6.14.3 |
EIP-7702 Core Concepts
Before diving into implementation, it's crucial to understand what makes EIP-7702 unique and powerful. Unlike previous account abstraction approaches, EIP-7702 allows your existing EOA to gain smart contract capabilities without changing its address or requiring complex migrations.
In this section, we will cover some of the core concepts of EIP-7702. For more details, refer to the EIP-7702 specification and the EIP-7702 Implementation Guide.
The Delegation Mechanism
When you delegate your EOA to a smart contract, you're essentially telling the Ethereum network to execute that contract's code whenever someone interacts with your EOA address. Think of it as temporarily "upgrading" your wallet with new capabilities while keeping your familiar address and private key.
The delegation process involves creating an authorization signature that points to your chosen implementation contract. Once this authorization is processed on-chain, your EOA's code slot contains a special delegation designator that redirects execution to the target contract.
// The delegation designator format: 0xef0100 + contract_address
// Example: 0xef01001234567890123456789012345678901234567890
This delegation remains active until you explicitly change or revoke it, making it a persistent enhancement rather than a per-transaction feature.
Transaction Construction
In standard Ethereum transactions, calling a smart contract function involves setting the to
field to the contract’s address and providing the encoded function call data. With EIP-7702, you set the to
field to the externally owned account (EOA) itself and include data that targets the implementation contract’s function, along with a signed authorization message.
This behavior is expected since the EOA acts as a smart contract during the EIP-7702 transaction, but it's highly confusing for developers used to the traditional model. Understanding this fundamental difference is crucial for successful EIP-7702 implementation.
Authorization Nonce
The user (EOA) signs an authorization message that includes the chain ID, nonce, delegation address, and signature components (y_parity, r, and s). One critical detail when constructing this message is how to correctly set the nonce.
In the case of non-sponsored transactions, where the same account both sends the transaction and authorizes the delegation, you must use the account’s current nonce plus one (current_nonce + 1
) in the signed authorization message.
As defined in the EIP-7702 specification:
The authorization list is processed before the execution portion of the transaction begins, but after the sender's nonce is incremented.
So, when the EVM processes the authorization list, it has already incremented the sender's nonce. During validation, it checks that the authority's on-chain nonce matches the nonce in the authorization.
Setting Up Your Development Environment
As we covered critical concepts in the previous section, let's get started with setting up your development environment.
Prerequisites
QuickNode Ethereum Sepolia Endpoint
To begin, you'll need a QuickNode account with an Ethereum Sepolia endpoint. If you don't have one, you can create one here. Then, create a new Ethereum Sepolia endpoint and keep the HTTPS URL provided handy for later use.
Ethereum Sepolia Faucet
To test EIP-7702 transactions, you'll need some Sepolia ETH and USDC (or another ERC-20 token) in your wallet.
For Sepolia ETH, you can use the QuickNode Multi-Chain Faucet:
- Go to the QuickNode Multi-Chain Faucet
- Connect or paste your wallet address and select the Ethereum Sepolia testnet
- You can also tweet for a bonus!
Note: You will need at least 0.001 ETH on Ethereum Mainnet to use the EVM faucets.
For USDC, you can use the Circle's Sepolia USDC faucet:
- Go to the Circle Sepolia USDC Faucet
- Paste your wallet address and request USDC tokens
Dependencies
Firstly, if you don't have TypeScript and tsx installed globally, you can install them using the following command:
npm install -g typescript tsx
tsx is a TypeScript execution engine that allows you to run TypeScript files directly without needing to compile them first. It’s a great tool for development and testing.
Then, create a new Node.js project and install the required packages. We'll use Ethers.js version 6.14.3 or higher, which includes full EIP-7702 support.
mkdir eip7702-example && cd eip7702-example
npm init -y
npm install ethers dotenv
Environment Variables
Create a .env
file in your project root to store your QuickNode endpoint URL, private keys, and other configuration details. This keeps sensitive information out of your source code.
# QuickNode Sepolia RPC endpoint
QUICKNODE_URL="YOUR_ENDPOINT_URL"
# Private keys for testing
FIRST_PRIVATE_KEY="YOUR_PRIVATE_KEY_FOR_FIRST_WALLET"
SPONSOR_PRIVATE_KEY="YOUR_PRIVATE_KEY_FOR_SPONSOR_WALLET"
# Our sample delegation contract address on Sepolia
DELEGATION_CONTRACT_ADDRESS = "0x69e2C6013Bd8adFd9a54D7E0528b740bac4Eb87C"
# USDC Address on Sepolia
USDC_ADDRESS = "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238"
Replace the placeholder values with your actual QuickNode endpoint URL and wallet private keys. The delegation contract address belongs to a sample implementation on Sepolia, which we covered in the EIP-7702 Implementation Guide. Feel free to use your own contract address if you have one deployed.
Contract ABI
To interact with the delegation contract, we need its ABI (Application Binary Interface). To get the full ABI of the contract, go to the Etherscan Sepolia Contract, copy the ABI section and paste it into a new file similar to the example below.
If you deploy your own contract, make sure to replace the ABI with your contract's ABI.
Create a new file named contract.ts
and add the following code:
export const contractABI = [
"function execute((address,uint256,bytes)[] calls) external payable",
"function execute((address,uint256,bytes)[] calls, bytes signature) external payable",
"function nonce() external view returns (uint256)"
];
Understanding the Delegation Contract
The sample delegation contract has two execute
functions, one for non-sponsored (direct) transactions and one for sponsored transactions. Both functions are suitable for batch execution. It also includes a nonce
function to track the current nonce for authorizations.
The reason of signature verification is to ensure that the EOA authorizes the transaction, especially in sponsored transactions where a different account pays the gas fees. If there isn't a such a check, anyone could execute transactions on behalf of the EOA without its consent.
struct Call {
address to;
uint256 value;
bytes data;
}
// Direct execution (msg.sender == address(this))
function execute(Call[] calldata calls) external payable;
// Sponsored execution (requires signature verification)
function execute(Call[] calldata calls, bytes calldata signature) external payable;
// Nonce for signature verification
function nonce() external view returns (uint256);
Send EIP-7702 Transactions with Ethers.js
Now that we have our environment set up and understand the core concepts, let's implement EIP-7702 transactions using Ethers.js. We'll cover the following steps:
- Initialize Ethers.js and Check Delegation Status: Set up Ethers.js with your QuickNode endpoint and check if your EOA has an existing delegation.
- Create Authorization for the EOA: Use the
nonce + 1
rule to create an authorization for the EOA, which will be used in subsequent transactions. - Send a Non-Sponsored EIP-7702 Transaction: Send a transaction where the EOA pays its own gas fees, using the
nonce + 1
rule for authorization. In this transaction, the EOA will send 0.002 and 0.001 ETH to two different addresses. - Send a Sponsored EIP-7702 Transaction: Send a transaction where a sponsor pays the gas fees. Since we set up the authorization in the previous step, we can no longer need a new authorization for the same EOA. In this transaction, the sponsor will execute the transaction where the EOA transfers 0.1 USDC and 0.001 ETH to the recipient address.
- Revoke Delegation: Optionally, revoke the delegation to restore your EOA to its original state.
- Run the Full Workflow: Combine all steps into a main function to execute the transactions.
Step 1: Initialize Ethers.js and Check Delegation Status
First, let's set up Ethers.js to connect to your QuickNode endpoint and verify if your EOA has an existing delegation.
This code initializes Ethers.js with your QuickNode endpoint and checks if your EOA has an active EIP-7702 delegation by inspecting its code (EIP-7702 delegations start with 0xef0100
).
Create a new file named index.ts
and add the following code:
import dotenv from "dotenv";
import { ethers } from "ethers";
import { contractABI } from "./contract";
dotenv.config();
// Global variables for reusability
let provider: ethers.JsonRpcProvider,
firstSigner: ethers.Wallet,
sponsorSigner: ethers.Wallet,
targetAddress: string,
usdcAddress: string,
recipientAddress: string;
async function initializeSigners() {
// Check environment variables
if (
!process.env.FIRST_PRIVATE_KEY ||
!process.env.SPONSOR_PRIVATE_KEY ||
!process.env.DELEGATION_CONTRACT_ADDRESS ||
!process.env.QUICKNODE_URL ||
!process.env.USDC_ADDRESS
) {
console.error("Please set your environmental variables in .env file.");
process.exit(1);
}
const quickNodeUrl = process.env.QUICKNODE_URL;
provider = new ethers.JsonRpcProvider(quickNodeUrl);
firstSigner = new ethers.Wallet(process.env.FIRST_PRIVATE_KEY, provider);
sponsorSigner = new ethers.Wallet(process.env.SPONSOR_PRIVATE_KEY, provider);
targetAddress = process.env.DELEGATION_CONTRACT_ADDRESS;
usdcAddress = process.env.USDC_ADDRESS;
recipientAddress =
(await provider.resolveName("vitalik.eth")) ||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
console.log("First Signer Address:", firstSigner.address);
console.log("Sponsor Signer Address:", sponsorSigner.address);
// Check balances
const firstBalance = await provider.getBalance(firstSigner.address);
const sponsorBalance = await provider.getBalance(sponsorSigner.address);
console.log("First Signer Balance:", ethers.formatEther(firstBalance), "ETH");
console.log(
"Sponsor Signer Balance:",
ethers.formatEther(sponsorBalance),
"ETH"
);
}
async function checkDelegationStatus(address = firstSigner.address) {
console.log("\n=== CHECKING DELEGATION STATUS ===");
try {
// Get the code at the EOA address
const code = await provider.getCode(address);
if (code === "0x") {
console.log(`❌ No delegation found for ${address}`);
return null;
}
// Check if it's an EIP-7702 delegation (starts with 0xef0100)
if (code.startsWith("0xef0100")) {
// Extract the delegated address (remove 0xef0100 prefix)
const delegatedAddress = "0x" + code.slice(8); // Remove 0xef0100 (8 chars)
console.log(`✅ Delegation found for ${address}`);
console.log(`📍 Delegated to: ${delegatedAddress}`);
console.log(`📝 Full delegation code: ${code}`);
return delegatedAddress;
} else {
console.log(`❓ Address has code but not EIP-7702 delegation: ${code}`);
return null;
}
} catch (error) {
console.error("Error checking delegation status:", error);
return null;
}
}
// STEP 2: Create Authorization for the EOA
// STEP 3: Send a Non-Sponsored EIP-7702 Transaction
// STEP 4: Send a Sponsored EIP-7702 Transaction
// STEP 5: Check USDC Balance
// STEP 6: Revoke Delegation
// STEP 7: Run the Full Workflow
After the first step, add code snippets in the following sections to the index.ts file to implement the remaining steps. At the end of this section, you will find the complete code example as well.
Step 2: Create Authorization for the EOA
Next, we need to create an authorization for the EOA. This authorization will be used in subsequent transactions to delegate execution to the specified contract. The createAuthorization
function takes a nonce
parameter. We'll use current_nonce + 1
when calling this function since we first send a non-sponsored transaction.
You can optionally specify the nonce
and chainId
when authorizing a transaction. In this example, we’ll explicitly set the nonce
. If you also want to specify the chainId
, feel free to uncomment the corresponding line. Otherwise, Ethers.js will automatically detect the appropriate chain ID for you.
async function createAuthorization(nonce: number) {
const auth = await firstSigner.authorize({
address: targetAddress,
nonce: nonce,
// chainId: 11155111, // Sepolia chain ID
});
console.log("Authorization created with nonce:", auth.nonce);
return auth;
}
Step 3: Send a Non-Sponsored EIP-7702 Transaction
In this transaction, your EOA both authorizes the delegation and sends the transaction, paying its own gas fees.
This function creates an authorization with nonce + 1
to delegate execution to the specified contract, then sends a transaction to transfer 0.001 ETH and 0.002 ETH in the same transaction. The type: 4
indicates an EIP-7702 transaction and the authorizationList
contains the authorization created in the previous step.
Notice how we create the contract instance pointing to the EOA address rather than the implementation contract address.
async function sendNonSponsoredTransaction() {
console.log("\n=== TRANSACTION 1: NON-SPONSORED (ETH TRANSFERS) ===");
const currentNonce = await firstSigner.getNonce();
console.log("Current nonce for first signer:", currentNonce);
// Create authorization with incremented nonce for same-wallet transactions
const auth = await createAuthorization(currentNonce + 1);
// Prepare calls for ETH transfers
const calls = [
// to address, value, data
[ethers.ZeroAddress, ethers.parseEther("0.001"), "0x"],
[recipientAddress, ethers.parseEther("0.002"), "0x"],
];
// Create contract instance and execute
const delegatedContract = new ethers.Contract(
firstSigner.address,
contractABI,
firstSigner
);
const tx = await delegatedContract["execute((address,uint256,bytes)[])"](
calls,
{
type: 4,
authorizationList: [auth],
}
);
console.log("Non-sponsored transaction sent:", tx.hash);
const receipt = await tx.wait();
console.log("Receipt for non-sponsored transaction:", receipt);
return receipt;
}
Step 4: Send a Sponsored EIP-7702 Transaction
Sponsored transactions represent transactions where a different wallet (sponsor) pays gas fees while executing operations on behalf of the EOA owner. This enables gasless user experiences.
The sponsored transaction flow is more complex because it requires signature verification. The EOA owner must sign a digest of the intended operations, proving they authorize the sponsor to execute those specific calls on their behalf. Otherwise, anyone could execute arbitrary transactions on the EOA's behalf, which would be a security risk. This signature will be decoded and verified by the delegation contract before executing the sponsored transaction.
This function sends a sponsored transaction to transfer 0.1 USDC and 0.001 ETH to the recipient address in the same transaction, with the sponsor paying gas fees.
// Function to create signature for sponsored calls, it's needed in the implementation contract
async function createSignatureForCalls(calls: any[], contractNonce: number) {
// Encode the calls for signature
let encodedCalls = "0x";
for (const call of calls) {
const [to, value, data] = call;
encodedCalls += ethers
.solidityPacked(["address", "uint256", "bytes"], [to, value, data])
.slice(2);
}
// Create the digest that needs to be signed
const digest = ethers.keccak256(
ethers.solidityPacked(["uint256", "bytes"], [contractNonce, encodedCalls])
);
// Sign the digest with the EOA's private key
return await firstSigner.signMessage(ethers.getBytes(digest));
}
async function sendSponsoredTransaction() {
console.log("\n=== TRANSACTION 2: SPONSORED (CONTRACT FUNCTION CALLS) ===");
// Prepare ERC20 transfer call data
const erc20ABI = [
"function transfer(address to, uint256 amount) external returns (bool)",
];
const erc20Interface = new ethers.Interface(erc20ABI);
const calls = [
[
usdcAddress,
0n,
erc20Interface.encodeFunctionData("transfer", [
recipientAddress,
ethers.parseUnits("0.1", 6), // 0.1 USDC
]),
],
[recipientAddress, ethers.parseEther("0.001"), "0x"],
];
// Create contract instance for sponsored transaction
const delegatedContract = new ethers.Contract(
firstSigner.address,
contractABI,
sponsorSigner
);
// Get contract nonce and create signature
const contractNonce = await delegatedContract.nonce();
const signature = await createSignatureForCalls(calls, contractNonce);
await checkUSDCBalance(firstSigner.address, "First Signer (Sender)");
// Execute sponsored transaction
const tx = await delegatedContract[
"execute((address,uint256,bytes)[],bytes)"
](calls, signature, {
// type: 4, // Reusing existing delegation.
// authorizationList: [auth], // New auth or EIP-7702 type are not needed.
});
console.log("Sponsored transaction sent:", tx.hash);
const receipt = await tx.wait();
console.log("Receipt for sponsored transaction:", receipt);
// Check USDC balances after transaction
console.log("\n--- USDC BALANCES AFTER SPONSORED TX ---");
await checkUSDCBalance(firstSigner.address, "First Signer (Sender)");
return receipt;
}
Step 5: Check USDC Balance
To verify that your transactions are working correctly, let's check the USDC balance of your EOA before and after the transactions.
async function checkUSDCBalance(address: string, label = "Address") {
const usdcContract = new ethers.Contract(
usdcAddress,
["function balanceOf(address owner) view returns (uint256)"],
provider
);
try {
const balance = await usdcContract.balanceOf(address);
const formattedBalance = ethers.formatUnits(balance, 6); // USDC has 6 decimals
console.log(`${label} USDC Balance: ${formattedBalance} USDC`);
return balance;
} catch (error) {
console.error(`Error getting USDC balance for ${label}:`, error);
return 0n;
}
}
Step 6: Revoke Delegation
Your EOA will remain delegated to the specified contract until you explicitly change or revoke it. You can revoke the delegation to restore your EOA to its original state by sending an EIP-7702 transaction with a zero-address authorization.
async function revokeDelegation() {
console.log("\n=== REVOKING DELEGATION ===");
const currentNonce = await firstSigner.getNonce();
console.log("Current nonce for revocation:", currentNonce);
// Create authorization to revoke (set address to zero address)
const revokeAuth = await firstSigner.authorize({
address: ethers.ZeroAddress, // Zero address to revoke
nonce: currentNonce + 1,
// chainId: 11155111,
});
console.log("Revocation authorization created");
// Send transaction with revocation authorization
const tx = await firstSigner.sendTransaction({
type: 4,
to: firstSigner.address,
authorizationList: [revokeAuth],
});
console.log("Revocation transaction sent:", tx.hash);
const receipt = await tx.wait();
console.log("Delegation revoked successfully!");
return receipt;
}
Step 7: Run the Full Workflow
Combine all steps into a main function to execute the transactions.
async function sendEIP7702Transactions() {
try {
// Initialize signers and get initial balances
await initializeSigners();
await provider.getBalance(firstSigner.address);
await provider.getBalance(sponsorSigner.address);
// Check delegation status before starting
await checkDelegationStatus();
// Execute transactions
const receipt1 = await sendNonSponsoredTransaction();
// Check delegation status after first transaction
await checkDelegationStatus();
const receipt2 = await sendSponsoredTransaction();
console.log("\n=== SUCCESS ===");
console.log("Both EIP-7702 transactions completed successfully!");
console.log("Non-sponsored tx block:", receipt1.blockNumber);
console.log("Sponsored tx block:", receipt2.blockNumber);
// Uncomment if you want to revoke delegation at the end
// await revokeDelegation();
return { receipt1, receipt2 };
} catch (error) {
console.error("Error in EIP-7702 transactions:", error);
throw error;
}
}
// Execute the main function
sendEIP7702Transactions()
.then(() => {
console.log("Process completed successfully.");
})
.catch((error) => {
console.error("Failed to send EIP-7702 transactions:", error);
});
Full Code Example
Click to view the full code example
import dotenv from "dotenv";
import { ethers } from "ethers";
import { contractABI } from "./contract";
dotenv.config();
// Global variables for reusability
let provider: ethers.JsonRpcProvider,
firstSigner: ethers.Wallet,
sponsorSigner: ethers.Wallet,
targetAddress: string,
usdcAddress: string,
recipientAddress: string;
async function initializeSigners() {
// Check environment variables
if (
!process.env.FIRST_PRIVATE_KEY ||
!process.env.SPONSOR_PRIVATE_KEY ||
!process.env.DELEGATION_CONTRACT_ADDRESS ||
!process.env.QUICKNODE_URL ||
!process.env.USDC_ADDRESS
) {
console.error("Please set your environmental variables in .env file.");
process.exit(1);
}
const quickNodeUrl = process.env.QUICKNODE_URL;
provider = new ethers.JsonRpcProvider(quickNodeUrl);
firstSigner = new ethers.Wallet(process.env.FIRST_PRIVATE_KEY, provider);
sponsorSigner = new ethers.Wallet(process.env.SPONSOR_PRIVATE_KEY, provider);
targetAddress = process.env.DELEGATION_CONTRACT_ADDRESS;
usdcAddress = process.env.USDC_ADDRESS;
recipientAddress =
(await provider.resolveName("vitalik.eth")) ||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
console.log("First Signer Address:", firstSigner.address);
console.log("Sponsor Signer Address:", sponsorSigner.address);
// Check balances
const firstBalance = await provider.getBalance(firstSigner.address);
const sponsorBalance = await provider.getBalance(sponsorSigner.address);
console.log("First Signer Balance:", ethers.formatEther(firstBalance), "ETH");
console.log(
"Sponsor Signer Balance:",
ethers.formatEther(sponsorBalance),
"ETH"
);
}
async function checkDelegationStatus(address = firstSigner.address) {
console.log("\n=== CHECKING DELEGATION STATUS ===");
try {
// Get the code at the EOA address
const code = await provider.getCode(address);
if (code === "0x") {
console.log(`❌ No delegation found for ${address}`);
return null;
}
// Check if it's an EIP-7702 delegation (starts with 0xef0100)
if (code.startsWith("0xef0100")) {
// Extract the delegated address (remove 0xef0100 prefix)
const delegatedAddress = "0x" + code.slice(8); // Remove 0xef0100 (8 chars)
console.log(`✅ Delegation found for ${address}`);
console.log(`📍 Delegated to: ${delegatedAddress}`);
console.log(`📝 Full delegation code: ${code}`);
return delegatedAddress;
} else {
console.log(`❓ Address has code but not EIP-7702 delegation: ${code}`);
return null;
}
} catch (error) {
console.error("Error checking delegation status:", error);
return null;
}
}
async function createAuthorization(nonce: number) {
const auth = await firstSigner.authorize({
address: targetAddress,
nonce: nonce,
// chainId: 11155111, // Sepolia chain ID
});
console.log("Authorization created with nonce:", auth.nonce);
return auth;
}
async function sendNonSponsoredTransaction() {
console.log("\n=== TRANSACTION 1: NON-SPONSORED (ETH TRANSFERS) ===");
const currentNonce = await firstSigner.getNonce();
console.log("Current nonce for first signer:", currentNonce);
// Create authorization with incremented nonce for same-wallet transactions
const auth = await createAuthorization(currentNonce + 1);
// Prepare calls for ETH transfers
const calls = [
// to address, value, data
[ethers.ZeroAddress, ethers.parseEther("0.001"), "0x"],
[recipientAddress, ethers.parseEther("0.002"), "0x"],
];
// Create contract instance and execute
const delegatedContract = new ethers.Contract(
firstSigner.address,
contractABI,
firstSigner
);
const tx = await delegatedContract["execute((address,uint256,bytes)[])"](
calls,
{
type: 4,
authorizationList: [auth],
}
);
console.log("Non-sponsored transaction sent:", tx.hash);
const receipt = await tx.wait();
console.log("Receipt for non-sponsored transaction:", receipt);
return receipt;
}
// Function to create signature for sponsored calls, it's needed in the implementation contract
async function createSignatureForCalls(calls: any[], contractNonce: number) {
// Encode the calls for signature
let encodedCalls = "0x";
for (const call of calls) {
const [to, value, data] = call;
encodedCalls += ethers
.solidityPacked(["address", "uint256", "bytes"], [to, value, data])
.slice(2);
}
// Create the digest that needs to be signed
const digest = ethers.keccak256(
ethers.solidityPacked(["uint256", "bytes"], [contractNonce, encodedCalls])
);
// Sign the digest with the EOA's private key
return await firstSigner.signMessage(ethers.getBytes(digest));
}
async function sendSponsoredTransaction() {
console.log("\n=== TRANSACTION 2: SPONSORED (CONTRACT FUNCTION CALLS) ===");
// Prepare ERC20 transfer call data
const erc20ABI = [
"function transfer(address to, uint256 amount) external returns (bool)",
];
const erc20Interface = new ethers.Interface(erc20ABI);
const calls = [
[
usdcAddress,
0n,
erc20Interface.encodeFunctionData("transfer", [
recipientAddress,
ethers.parseUnits("0.1", 6), // 0.1 USDC
]),
],
[recipientAddress, ethers.parseEther("0.001"), "0x"],
];
// Create contract instance for sponsored transaction
const delegatedContract = new ethers.Contract(
firstSigner.address,
contractABI,
sponsorSigner
);
// Get contract nonce and create signature
const contractNonce = await delegatedContract.nonce();
const signature = await createSignatureForCalls(calls, contractNonce);
await checkUSDCBalance(firstSigner.address, "First Signer (Sender)");
// Execute sponsored transaction
const tx = await delegatedContract[
"execute((address,uint256,bytes)[],bytes)"
](calls, signature, {
// type: 4,
// authorizationList: [auth], // Reusing existing delegation, no new auth needed
});
console.log("Sponsored transaction sent:", tx.hash);
const receipt = await tx.wait();
console.log("Receipt for sponsored transaction:", receipt);
// Check USDC balances after transaction
console.log("\n--- USDC BALANCES AFTER SPONSORED TX ---");
await checkUSDCBalance(firstSigner.address, "First Signer (Sender)");
return receipt;
}
async function revokeDelegation() {
console.log("\n=== REVOKING DELEGATION ===");
const currentNonce = await firstSigner.getNonce();
console.log("Current nonce for revocation:", currentNonce);
// Create authorization to revoke (set address to zero address)
const revokeAuth = await firstSigner.authorize({
address: ethers.ZeroAddress, // Zero address to revoke
nonce: currentNonce + 1,
// chainId: 11155111,
});
console.log("Revocation authorization created");
// Send transaction with revocation authorization
const tx = await firstSigner.sendTransaction({
type: 4,
to: firstSigner.address,
authorizationList: [revokeAuth],
});
console.log("Revocation transaction sent:", tx.hash);
const receipt = await tx.wait();
console.log("Delegation revoked successfully!");
return receipt;
}
async function checkUSDCBalance(address: string, label = "Address") {
const usdcContract = new ethers.Contract(
usdcAddress,
["function balanceOf(address owner) view returns (uint256)"],
provider
);
try {
const balance = await usdcContract.balanceOf(address);
const formattedBalance = ethers.formatUnits(balance, 6); // USDC has 6 decimals
console.log(`${label} USDC Balance: ${formattedBalance} USDC`);
return balance;
} catch (error) {
console.error(`Error getting USDC balance for ${label}:`, error);
return 0n;
}
}
async function sendEIP7702Transactions() {
try {
// Initialize signers and get initial balances
await initializeSigners();
await provider.getBalance(firstSigner.address);
await provider.getBalance(sponsorSigner.address);
// Check delegation status before starting
await checkDelegationStatus();
// Execute transactions
const receipt1 = await sendNonSponsoredTransaction();
// Check delegation status after first transaction
await checkDelegationStatus();
const receipt2 = await sendSponsoredTransaction();
console.log("\n=== SUCCESS ===");
console.log("Both EIP-7702 transactions completed successfully!");
console.log("Non-sponsored tx block:", receipt1.blockNumber);
console.log("Sponsored tx block:", receipt2.blockNumber);
// Uncomment if you want to revoke delegation at the end
// await revokeDelegation();
return { receipt1, receipt2 };
} catch (error) {
console.error("Error in EIP-7702 transactions:", error);
throw error;
}
}
// Standalone function to revoke delegation (can be called separately)
async function revokeDelegationStandalone() {
try {
await initializeSigners();
await checkDelegationStatus();
await revokeDelegation();
await checkDelegationStatus();
console.log("Delegation revocation completed successfully!");
} catch (error) {
console.error("Failed to revoke delegation:", error);
throw error;
}
}
// Execute the main function
sendEIP7702Transactions()
.then(() => {
console.log("Process completed successfully.");
})
.catch((error) => {
console.error("Failed to send EIP-7702 transactions:", error);
});
// Uncomment to run the standalone revocation function
// revokeDelegationStandalone().catch((error) => {
// console.error("Failed to revoke delegation:", error);
// });
Step 8: Run the Code
To run the code, execute the following command in your terminal:
tsx index.ts
Review Results
After running the code, you should see output similar to the following:
Let's break down the key parts of the output:
Delegation Status: At the start, the system checks whether the "First Signer" has a delegation (a smart contract account associated with their EOA). The result shows:
No delegated contract found for 0x5DfD0ec499A16F2a0f529f16fcE06bbaAb4ef8F8
This confirms that initially, the "First Signer" is just a regular EOA (Externally Owned Account).
Non-Sponsored Transaction: The first transaction is a batch transaction sent directly by the "First Signer". It:
- Has an
authorization
field attached, indicating it's an EIP-7702 transaction. - Sends 0.001 ETH and 0.002 ETH to two recipients.
- Is initiated and paid for by the "First Signer" without external sponsorship.
Delegation Contract Deployment: After the first transaction, the system detects that a delegation contract (smart account) has now been deployed:
Delegated to: 0x6C2cE0d8c9d4f45e2bb78a44bac4e8b7c
Sponsored Transaction (Smart Account Call): The second transaction is a sponsored transaction, meaning:
- It's initiated on behalf of the "First Signer", but sent and paid for by a sponsor address.
- The transaction sends 0.1 USDC and 0.001 ETH.
- This transaction also uses EIP-7702 features, even though it no longer needs a separate authentication payload.
Final Balance Check: The script confirms the updated balance, showing:
First Signer (Sender) - USDC Balance: 0.4 USDC
Troubleshooting
When implementing EIP-7702 transactions, you may encounter several common issues. Understanding these problems and their solutions will help you build more robust applications.
Function Signature Ambiguity
If your delegation contract has multiple functions with the same name (i.e., execute
), Ethers.js may not know which one to call. This manifests as an "ambiguous function description" error.
// Problem: Ethers.js can't determine which execute function to use
const tx = await contract.execute(calls); // ❌ Ambiguous
// Solution: Use specific function signature
const tx = await contract["execute((address,uint256,bytes)[])"](calls); // ✅ Specific
Object vs Array Parameter Mismatch
The EIP-7702 contract we use in this guide expects tuple parameters as arrays, not JavaScript objects.
// Problem: Using objects for call parameters
const calls = [
{ to: "0x123...", value: 100n, data: "0x" } // ❌ Object format
];
// Solution: Use arrays in the correct order
const calls = [
["0x123...", 100n, "0x"] // ✅ Array format matching (address,uint256,bytes)
];
Nonce Management
Nonce management is crucial for EIP-7702 transactions. Here are some common issues and their solutions:
// For same-wallet transactions (non-sponsored)
const currentNonce = await signer.getNonce();
const auth = await signer.authorize({
nonce: currentNonce + 1 // ✅ Increment for same wallet
});
// For different-wallet transactions (sponsored)
const auth = await signer.authorize({
nonce: currentNonce // ✅ Use current nonce for different wallets
});
Signature Verification
When sending sponsored transactions, ensure the signature is correctly formed. The signature must match the digest of the intended operations.
// Ensure your signature creation matches contract expectations
const digest = ethers.keccak256(
ethers.solidityPacked(
["uint256", "bytes"], // Must match contract's expectation
[contractNonce, encodedCalls]
)
);
// Sign with EOA's key, not sponsor's key
const signature = await eoaSigner.signMessage(ethers.getBytes(digest));
Conclusion
You’ve successfully sent EIP-7702 transactions using Ethers.js, enabling advanced features like batch transactions and gas sponsorship.
While it's groundbreaking, EIP-7702 related improvements are still evolving. As you continue to explore EIP-7702, consider the following:
- Stay Updated: Follow the latest developments in EIP-7702 and related standards. Follow us on X for updates and join our Discord server to engage with the community.
- Experiment: Try different transaction types, such as sponsored transactions with multiple sponsors or complex batch operations.
- Contribute: If you find bugs or have ideas for improvements, consider contributing to the EIP-7702 discussions or implementations.
- Give feedback: Share your experiences and thoughts with us on the form below. Your feedback helps us improve our guides and resources.
We ❤️ Feedback!
Let us know if you have any feedback or requests for new topics. We'd love to hear from you.