はじめに
2025年5月に、イーサリアムの大規模なアップグレードである「Pectra」が実装されました。そこで新たに導入されたERC(Ethereum Request for Comments)の中に、ERC-7702があります。これはERC-4337とともに、アカウント抽象化を実現する規格です。
本記事では、ERC-7702によって実現できるGas Sponsor(第三者によるGasの肩代わり)と、トランザクションのバッチ実行を実践します。
アカウント抽象化
アカウント抽象化とは
EthereumにおけるアカウントにはコントラクトアカウントとEOAの二つがあります(コントラクトアカウント=スマートコントラクト、EOA=MetaMask等でユーザーが操作する通常のウォレットという理解です)。EOAは広く使われているものの、いくつか課題が指摘されています1。アカウント抽象化は、「すべてのアカウントがコントラクトによる柔軟なふるまいを持つ」ことを目指す考え方です。
ERC-4337によるアカウント抽象化
ERC-4337では、コントラクトベースのアカウントである「スマートアカウント(Smart Account)」という新たな枠組みが広がりました。スマートアカウントを使うことで、第三者によるGasの肩代わりや秘密鍵紛失時のリカバリなど、従来のEOAでは行えなかった機能を実装することが可能になりました。
ただ、スマートアカウントはコントラクトのためオンチェーンにデプロイする必要があったり、既存のEOA利用者は資産をスマートアカウントに移行する必要があったりと、新たな利用ハードルも生まれました。
2025年7月に公開された記事を参照すると、Ethereumの総アカウント数1億2700万に対してスマートアカウントの数は40万強となっており、普及割合としてはまだ小さい状況です。
補足ですが、ERC-4337の実装について過去に記事を執筆しているので、よければ読んでみてください。
ERC-7702によるアカウント抽象化
ERC-7702は、ERC-4337と互換性のある形で、既存のEOAもアカウント抽象化の恩恵を受けられるようにする規格です。
ERC-7702では、EOAがコントラクトに権限移譲することで、一時的にスマートアカウントのように振舞うことができます。移譲先コントラクトのロジックを組むことで、EOAをより柔軟に動かせることができます。
実践
ここからは、EIP-7702を用いたGas Sponsorとトランザクションのバッチ実行を実際に試します。
実行環境
- OS: Windows11
- node.js: 22.20.0
- Solidity 0.8.20
ユースケース
以下のケースを想定します。
- Sender、Recipient1、Recipient2、Gas Sponsorの4アカウントが存在する
- Senderはコントラクトに権限を委譲し、コントラクトウォレットのように振舞う
- SenderからRecipient1に0.1USDCを送る
- SenderからRecipient2に0.2USDCを送る
- 上記トランザクションはバッチ化され、一括で実行される
- 上記トランザクション実行により要求されるGasはGas Sponsorが肩代わりする
ソースコード
ソースコードは以下の通りです。コントラクトはEthereum Spoliaテストネットにデプロイしています。
コントラクト
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
contract BatchCallAndSponsor {
using ECDSA for bytes32;
uint256 public nonce;
struct Call {
address to;
uint256 value;
bytes data;
}
event CallExecuted(address indexed sender, address indexed to, uint256 value, bytes data);
event BatchExecuted(uint256 indexed nonce, Call[] calls);
event DebugAddresses(address recovered, address contractAddress);
function execute(Call[] calldata calls, bytes calldata signature) external payable {
bytes memory encodedCalls;
for (uint256 i = 0; i < calls.length; i++) {
encodedCalls = abi.encodePacked(encodedCalls, calls[i].to, calls[i].value, calls[i].data);
}
bytes32 digest = keccak256(abi.encodePacked(nonce, encodedCalls));
bytes32 ethSignedMessageHash = MessageHashUtils.toEthSignedMessageHash(digest);
address recovered = ECDSA.recover(ethSignedMessageHash, signature);
emit DebugAddresses(recovered, address(this));
require(recovered == address(this), "Invalid signature");
uint256 currentNonce = nonce;
nonce++;
for (uint256 i = 0; i < calls.length; i++) {
(bool success,) = calls[i].to.call{value: calls[i].value}(calls[i].data);
require(success, "Call reverted");
emit CallExecuted(msg.sender, calls[i].to, calls[i].value, calls[i].data);
}
emit BatchExecuted(currentNonce, calls);
}
fallback() external payable {}
receive() external payable {}
}
コントラクトABI
exports.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)"
];
コントラクト呼び出し
const dotenv = __importDefault(require("dotenv"));
const ethers = require("ethers");
const contract = require("./contract");
dotenv.default.config();
let provider, sender, sponsor, recipient1, recipient2, targetAddress, usdcAddress;
/**
* EIP-7702に関連する一連のトランザクションを順に実行する。
*/
async function sendEIP7702Transactions() {
try {
await initializeSigners();
await checkDelegationStatus();
await delegation();
await checkDelegationStatus();
const receipt = await sendSponsoredTransaction();
await checkDelegationStatus();
await revokeDelegation();
return { receipt };
}
catch (error) {
console.error("Error in EIP-7702 transactions:", error);
throw error;
}
}
sendEIP7702Transactions()
.then(() => {
console.log("Process completed successfully.");
})
.catch((error) => {
console.error("Failed to send EIP-7702 transactions:", error);
});
/**
* ウォレットとプロバイダーを初期化する。
* @returns {Promise<void>}
*/
async function initializeSigners() {
// 環境変数のチェック
if (!process.env.SENDER_PRIVATE_KEY ||
!process.env.SPONSOR_PRIVATE_KEY ||
!process.env.DELEGATION_CONTRACT_ADDRESS ||
!process.env.RPC_URL ||
!process.env.USDC_ADDRESS ||
!process.env.RECIPIENT1_PRIVATE_KEY ||
!process.env.RECIPIENT2_PRIVATE_KEY) {
console.error("環境変数をセットしてください");
process.exit(1);
}
const rpcUrl = process.env.RPC_URL;
provider = new ethers.JsonRpcProvider(rpcUrl);
// アカウントの設定
sender = new ethers.Wallet(process.env.SENDER_PRIVATE_KEY, provider);
sponsor = new ethers.Wallet(process.env.SPONSOR_PRIVATE_KEY, provider);
recipient1 = new ethers.Wallet(process.env.RECIPIENT1_PRIVATE_KEY, provider);
recipient2 = new ethers.Wallet(process.env.RECIPIENT2_PRIVATE_KEY, provider);
targetAddress = process.env.DELEGATION_CONTRACT_ADDRESS;
usdcAddress = process.env.USDC_ADDRESS;
}
/**
* 指定したアドレス(デフォルトはsenderアドレス)の
* EIP-7702移譲コントラクトのコードを取得し、
* 移譲ステータスを判定してログ出力する。
*/
async function checkDelegationStatus(address = sender.address) {
console.log("移譲ステータスを確認します");
try {
const code = await provider.getCode(address);
if (code === "0x") {
console.log(`移譲済みコントラクトがありません。EOAアドレス: ${address}`);
return null;
}
// 委任済みコントラクトがあれば、コントラクトアドレスを表示
// "0xef0100"はEIP-7702の移譲コードであることを示す接頭辞
if (code.startsWith("0xef0100")) {
const delegatedAddress = "0x" + code.slice(8);
console.log(`移譲済みコントラクトアドレス:${delegatedAddress}\nEOAアドレス:${address}`);
console.log(`コード: ${code}`);
return delegatedAddress;
}
else {
console.log(`EIP-7702に準拠しないコードが存在します。コード: ${code}`);
return null;
}
}
catch (error) {
console.error("コントラクト移譲ステータス確認でエラーが発生しました。", error);
return null;
}
}
/**
* 移譲Authorization署名を生成し、トランザクションを送信する。
*/
async function delegation() {
const currentNonce = await sender.getNonce();
console.log("Current nonce :", currentNonce);
const auth = await sender.authorize({
address: targetAddress,
nonce: currentNonce,
chainId: 11155111
});
console.log("authorization created");
const tx = await sponsor.sendTransaction({
type: 4, // EIP-7702トランザクションのタイプは4(0x04)と定められている
to: sender.address, //移譲元のEOAアドレスへ送信する
authorizationList: [auth],
});
const receipt = await tx.wait();
console.log("delegation completed");
return receipt;
}
/**
* 移譲を撤回するAuthorization署名を作成し、トランザクションを送信する。
*/
async function revokeDelegation() {
const currentNonce = await sender.getNonce();
console.log("Current nonce for revocation:", currentNonce);
const revokeAuth = await sender.authorize({
address: ethers.ZeroAddress,
nonce: currentNonce,
chainId: 11155111
});
console.log("Revocation authorization created");
const tx = await sponsor.sendTransaction({
type: 4,
to: sender.address,
authorizationList: [revokeAuth],
});
const receipt = await tx.wait();
return receipt;
}
/**
* 指定アドレスのUSDC残高を取得し、出力する。
*/
async function checkUSDCBalance(address, 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;
}
}
/**
* 指定アドレスのETH残高を取得し、出力する。
*/
async function checkETHBalance(address, label = "Address") {
try {
const balance = await provider.getBalance(address);
console.log(`${label} ETH Balance:, ${ethers.formatEther(balance)} ETH`);
}
catch (error) {
console.error(`Error getting ETH balance for ${label}:`, error);
return 0n;
}
}
/**
* ERC20トークン(USDC)の複数送金バッチトランザクションを
* 署名付きでコントラクトに送信する。
*/
async function sendSponsoredTransaction() {
console.log("トランザクション送信を開始します。");
// ERC20トークンのTransfer()のABIを定義
const erc20ABI = [
"function transfer(address to, uint256 amount) external returns (bool)",
];
const erc20Interface = new ethers.Interface(erc20ABI);
const currentNonce = await sender.getNonce();
console.log("現在のnonce:", currentNonce);
const calls = [
// 1つめのトランザクション
// 0.1USDCをrecipient1Addressに送付する
[
usdcAddress,
0n, //ETHは送付しないため0
erc20Interface.encodeFunctionData("transfer", [
recipient1.address,
ethers.parseUnits("0.1", 6), // 0.1 USDC
])
],
// 2つめのトランザクション
// 0.2USDCをrecipient2Addressに送付する
[
usdcAddress,
0n,
erc20Interface.encodeFunctionData("transfer", [
recipient2.address,
ethers.parseUnits("0.2", 6), // 0.2 USDC
])
]
];
// コントラクトインスタンス作成
const delegatedContract = new ethers.Contract(
sender.address, //宛先(※EIP-7702ではコントラクト移譲元のEOAアドレス)
contract.contractABI,
sponsor // コントラクトを操作(トランザクションを発行)する主体
);
// コントラクトが管理するnonceを取得
const contractNonce = await delegatedContract.nonce();
const signature = await createSignatureForCalls(calls, contractNonce);
// Tx送信前のUSDC残高チェック
await checkUSDCBalance(sender.address, "Sender");
await checkUSDCBalance(recipient1.address, "Recipient1");
await checkUSDCBalance(recipient2.address, "Recipient2");
// Tx送信前のETH残高チェック
await checkETHBalance(sender.address, "Sender");
await checkETHBalance(sponsor.address, "Sponsor");
const tx = await delegatedContract["execute((address,uint256,bytes)[],bytes)"](calls, signature, {});
console.log("calls :", calls);
console.log("signature :", signature);
console.log("トランザクション送信完了 ハッシュ:", tx.hash);
const receipt = await tx.wait();
console.log("トランザクション送信結果取得完了:", receipt);
// Tx送信後のUSDC残高チェック
await checkUSDCBalance(sender.address, "Sender");
await checkUSDCBalance(recipient1.address, "Recipient1");
await checkUSDCBalance(recipient2.address, "Recipient2");
// Tx送信後のETH残高チェック
await checkETHBalance(sender.address, "Sender");
await checkETHBalance(sponsor.address, "Sponsor");
return receipt;
}
/**
* 複数のcall(送信先アドレス・value・data)を
* 連結してハッシュ化し、署名を作成する。
*/
async function createSignatureForCalls(calls, contractNonce) {
let encodedCalls = "0x";
for (const call of calls) {
const [to, value, data] = call;
encodedCalls += ethers.ethers
.solidityPacked(["address", "uint256", "bytes"], [to, value, data])
.slice(2);
}
const digest = ethers.keccak256(ethers.solidityPacked(["uint256", "bytes"], [contractNonce, encodedCalls]));
return await sender.signMessage(ethers.getBytes(digest));
}
コントラクト呼び出しコードの流れ
以下、コントラクト呼び出し側の大まかな流れです。
- Senderがコントラクトへの権限移譲に用いる署名を作成
- Sponsorが手順1の署名を用いて権限移譲トランザクションを送信
- Senderがバッチトランザクションの内容(2回の送金)を表すcallsを作成
- Senderが3.のcallsとコントラクトが管理するnonceから、バッチトランザクション実行に用いる署名を作成
- Sponsorが手順3のcallsと手順4の署名を用いてバッチトランザクションを送信
1.権限移譲署名の作成 & 2.権限移譲トランザクションの送信
async function delegation() {
const currentNonce = await sender.getNonce();
console.log("Current nonce :", currentNonce);
const auth = await sender.authorize({
address: targetAddress,
nonce: currentNonce,
chainId: 11155111
});
console.log("authorization created");
const tx = await sponsor.sendTransaction({
type: 4, // EIP-7702トランザクションのタイプは4(0x04)と定められている
to: sender.address, //移譲元のEOAアドレスへ送信する
authorizationList: [auth],
});
const receipt = await tx.wait();
console.log("delegation completed");
return receipt;
}
sender.authorize()により、Senderがコントラクトへ権限移譲するための署名を作成します。署名には、移譲先コントラクトのアドレス・移譲元EOAのnonce・チェーンID(今回はEthereum SepoliaのチェーンID)が含まれます。
この署名を用いてトランザクションを送信しますが、ポイントはトランザクションの送信者 = 権限移譲元のEOAである必要はない点です。Senderが作成したauthorize署名さえあれば、だれでもトランザクションの送信が可能です。今回はSponsorがトランザクションを送信しているため、発生するGasもSponsorが負担することになります。
3.callsの作成
async function sendSponsoredTransaction() {
--中略---
const calls = [
// 1つめのトランザクション
// 0.1USDCをrecipient1Addressに送付する
[
usdcAddress,
0n, //ETHは送付しないため0
erc20Interface.encodeFunctionData("transfer", [
recipient1.address,
ethers.parseUnits("0.1", 6), // 0.1 USDC
])
],
// 2つめのトランザクション
// 0.2USDCをrecipient2Addressに送付する
[
usdcAddress,
0n,
erc20Interface.encodeFunctionData("transfer", [
recipient2.address,
ethers.parseUnits("0.2", 6), // 0.2 USDC
])
]
];
EIP7702sample.solのexecute(Call[] calldata calls, bytes calldata signature)の引数となるcallsを作成します。calllsに含まれるcallそれぞれが、実行されるトランザクションの内容を表します。ここでは「USDCコントラクトのTransferを実行し、Recipient1に0.1USDC送付」するトランザクションと、「USDCコントラクトのTransferを実行し、Recipient2に0.2USDC送付」するトランザクションの二つを構築しています。
4.バッチトランザクション実行署名の作成 & 5.バッチトランザクションの送信
async function sendSponsoredTransaction() {
--中略---
// コントラクトインスタンス作成
const delegatedContract = new ethers.Contract(
sender.address, //宛先(※EIP-7702ではコントラクト移譲元のEOAアドレス)
contract.contractABI,
sponsor // トランザクションを送信する主体
);
// コントラクトが管理するnonceを取得
const contractNonce = await delegatedContract.nonce();
const signature = await createSignatureForCalls(calls, contractNonce);
// Tx送信前のUSDC残高チェック
await checkUSDCBalance(sender.address, "Sender");
await checkUSDCBalance(recipient1.address, "Recipient1");
await checkUSDCBalance(recipient2.address, "Recipient2");
// Tx送信前のETH残高チェック
await checkETHBalance(sender.address, "Sender");
await checkETHBalance(sponsor.address, "Sponsor");
const tx = await delegatedContract["execute((address,uint256,bytes)[],bytes)"](calls, signature, {});
まずnew ethers.Contract()で移譲先コントラクトのインスタンスを生成します。コントラクトへの移譲と同様、トランザクションの送信者はsponsorなので、Gasもsponsorが負担します。
次に、EIP7702sample.solのnonce()を実行し取得したnonce(EOAが管理するnonceではなく、コントラクト側で管理するnonce)から、今回のトランザクション実行用の署名を生成します。この署名はコントラクト側で、callsの実行前に検証されます。これにより、リプレイ攻撃(同一のsenderの署名が繰り返し使い回される)を防止しています。
最後に、callsと署名を引数にEIP7702sample.solのexecute(Call[] calldata calls, bytes calldata signature)を実行します。
実行結果
上記コードを実行し、結果を確認します。
node ./index.js
トランザクション詳細
トランザクションハッシュはコンソールに出力するよう実装されています。
トランザクション送信完了 ハッシュ: 0x0a26b64fc6e50abe26ba3cdcf42a178f01d8ab234035d827a3d262ff9ae8e9ef
このハッシュをEtherscanで検索し、トランザクションの詳細を確認します。
「ERC-20 Tokens Transferred」欄を見ると、それぞれ異なる相手に0.1USDCのTransferと0.2USDCのTransferが行われていることを確認できます。このような形で2つのトランザクションを束ねたバッチトランザクションの実行が確認できます。
バッチトランザクション自体は「SponsorからSender宛て」に送信するため、「Interacted With (To)」欄のアドレスにはSenderのアドレスが入ります。バッチトランザクションに含まれる2つのトランザクションはそれぞれ「SenderからRecipient1,2宛て」に送信するため、「ERC-20 Tokens Transferred」欄の「From」アドレスがSenderのアドレスになります。
残高の変化
トランザクション実行前後のUSDC・ETHの残高についてもコンソール出力を確認します。
Tx実行前:
※何度かテストしている関係上、Recipient1,2それぞれ既にUSDCを保持しています
Sender USDC Balance: 10.0 USDC
Recipient1 USDC Balance: 1.2 USDC
Recipient2 USDC Balance: 2.4 USDC
Sender ETH Balance:, 0.0 ETH
Sponsor ETH Balance:, 0.001895453152447808 ETH
Tx実行後:
Sender USDC Balance: 9.7 USDC
Recipient1 USDC Balance: 1.3 USDC
Recipient2 USDC Balance: 2.6 USDC
Sender ETH Balance:, 0.0 ETH
Sponsor ETH Balance:, 0.001891033585986108 ETH
SenderのUSDC残高は10→9.7、Recipient1のUSDC残高は1.2→1.3、Recipient2のUSDC残高は2.4→2.6となっています。また、SenderのETH残高は変化がなくSponsorの残高はわずかに減少しています。
残高の変化からも、「SenderからRecipient1,2へのUSDC送付が成功していること」、「SponsorがGasを支払ったこと」が読み取れます。
所感
EOAであっても、事実上コントラクトで記述できるあらゆるロジックを組み込めるようになったので、ウォレットの拡張性は確実に向上したといえます。Gasの肩代わりや秘密鍵紛失時のリカバリといったユーザーフレンドリーな処理を組み込むことが可能になった一方で、悪意のあるコントラクトに権限委譲してしまうと取り返しのつかないことになるリスクも新たに発生しました。そのため、オフチェーン含むあらゆる署名は慎重に行う意識が重要なことは変わらないでしょう。
-
・Gasを自身で、かつネイティブトークンで支払わなければならないのでUXが悪い
・秘密鍵を紛失すると二度と復旧できない等 ↩

