はじめに
Ethereum の Pectra アップデートでは、いくつかの注目すべき機能が実装されました。
その中でも、EIP-7702: Set Code for EOAs という機能を実際に実装・使用してみたいと思います。
EOAアカウントコード
EIP-7702は、アカウント抽象化(account abstraction)の普及に向けた大きな一歩です。
この機能により、ユーザーは自身のウォレットアドレス(EOA)をスマートコントラクトと連携させることができます。
EIP-7702は、特定の機能を備えた新しいタイプのトランザクションを導入します。
具体的には、ウォレット所有者が、自身のウォレットアドレスを任意のスマートコントラクトに紐づけるための承認署名を行うことができるようになります。このEIPにより、ユーザーは、トランザクションのバッチ処理、ガス代不要のトランザクション、独自の資産管理機能、代替的なアカウント復旧方法など、様々な機能を備えたプログラマブルウォレットを利用できるようになります。
このハイブリッド方式は、EOAのシンプルさと、コントラクトベースのアカウントの柔軟性を兼ね備えています。
環境
以下の環境を使用します。
ブロックチェーン | |
コントラクト開発環境 | |
ライブラリ |
EIP-7702: Set Code for EOAs
安全な委任コントラクトの実装
EOA に設定する委任コントラクトをデプロイします。
EIP-7702 用の委任コントラクトについては、いくつか存在しますが、比較的ソースコードが見やすいtutorial-buildbear-eip-7702のコントラクトを使用します。
デプロイされたコントラクトは、以下となります。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
/**
* @title BatchCallAndSponsor
* @notice An educational contract that allows batch execution of calls with nonce and signature verification.
*
* When an EOA upgrades via EIP‑7702, it delegates to this implementation.
* Off‑chain, the account signs a message authorizing a batch of calls. The message is the hash of:
* keccak256(abi.encodePacked(nonce, calls))
* The signature must be generated with the EOA’s private key so that, once upgraded, the recovered signer equals the account’s own address (i.e. address(this)).
*
* This contract provides two ways to execute a batch:
* 1. With a signature: Any sponsor can submit the batch if it carries a valid signature.
* 2. Directly by the smart account: When the account itself (i.e. address(this)) calls the function, no signature is required.
*
* Replay protection is achieved by using a nonce that is included in the signed message.
*/
contract BatchCallAndSponsor {
using ECDSA for bytes32;
/// @notice A nonce used for replay protection.
uint256 public nonce;
/// @notice Represents a single call within a batch.
struct Call {
address to;
uint256 value;
bytes data;
}
/// @notice Emitted for every individual call executed.
event CallExecuted(address indexed sender, address indexed to, uint256 value, bytes data);
/// @notice Emitted when a full batch is executed.
event BatchExecuted(uint256 indexed nonce, Call[] calls);
/**
* @notice Executes a batch of calls using an off–chain signature.
* @param calls An array of Call structs containing destination, ETH value, and calldata.
* @param signature The ECDSA signature over the current nonce and the call data.
*
* The signature must be produced off–chain by signing:
* The signing key should be the account’s key (which becomes the smart account’s own identity after upgrade).
*/
function execute(Call[] calldata calls, bytes calldata signature) external payable {
// Compute the digest that the account was expected to sign.
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);
// Recover the signer from the provided signature.
address recovered = ECDSA.recover(ethSignedMessageHash, signature);
require(recovered == address(this), "Invalid signature");
_executeBatch(calls);
}
/**
* @notice Executes a batch of calls directly.
* @dev This function is intended for use when the smart account itself (i.e. address(this))
* calls the contract. It checks that msg.sender is the contract itself.
* @param calls An array of Call structs containing destination, ETH value, and calldata.
*/
function execute(Call[] calldata calls) external payable {
require(msg.sender == address(this), "Invalid authority");
_executeBatch(calls);
}
/**
* @dev Internal function that handles batch execution and nonce incrementation.
* @param calls An array of Call structs.
*/
function _executeBatch(Call[] calldata calls) internal {
uint256 currentNonce = nonce;
nonce++; // Increment nonce to protect against replay attacks
for (uint256 i = 0; i < calls.length; i++) {
_executeCall(calls[i]);
}
emit BatchExecuted(currentNonce, calls);
}
/**
* @dev Internal function to execute a single call.
* @param callItem The Call struct containing destination, value, and calldata.
*/
function _executeCall(Call calldata callItem) internal {
(bool success,) = callItem.to.call{value: callItem.value}(callItem.data);
require(success, "Call reverted");
emit CallExecuted(msg.sender, callItem.to, callItem.value, callItem.data);
}
// Allow the contract to receive ETH (e.g. from DEX swaps or other transfers).
fallback() external payable {}
receive() external payable {}
}
コード設定トランザクション
EOA にコード(コントラクト)を設定・解除するトランザクションを送信してみます。
コード設定
以下のスクリプトでコードを設定することができます。
const signers = await ethers.getSigners();
const account = signers[0];
const CONTRACT_ADDRESS = "0xb51e0d2aEa249ee310DbB3a69E1C588819E2df09";
const code = await ethers.provider.getCode(account.address);
console.log(`Account code: ${code}`);
if (code === "0x") {
const nonce = await account.getNonce();
console.log(`Account nonce: ${nonce}`);
const auth = await account.authorize({
address: CONTRACT_ADDRESS,
nonce: nonce + 1,
});
const tx = await account.sendTransaction({
to: account.address,
type: 4,
authorizationList: [auth],
});
console.log(`Transaction hash: ${tx.hash}`);
const receipt = await tx.wait();
console.log(`Transaction receipt: ${receipt?.status}`);
const newCode = await ethers.provider.getCode(account.address);
console.log(`New account code: ${newCode}`);
} else {
console.log("Account code already exists at the address.");
}
結果が以下となります。
Account code: 0x
Account nonce: 5
Transaction hash: 0xa8e8f7113fb8eeac90cae56d205a8e5c1da0cac526ad6c5777e1c5465981eacf
Transaction receipt: 1
New account code: 0xef0100b51e0d2aea249ee310dbb3a69e1c588819e2df09
EOA に設定されたコードは、接頭の 0xef0100
にコントラクトアドレス(b51e0d2aea249ee310dbb3a69e1c588819e2df09
)が連結されたコードとなります。
ポイント
ethers には、EIP-7702用の署名 authorize が用意されています。
これを用いて、コードとして設定するコントラクトアドレスと nonce+1
の値を指定して署名します。
トランザクションのタイプは 4
を指定して、署名したものを authorizationList
に設定し、自身のアドレスへ送信します。
const nonce = await account.getNonce();
console.log(`Account nonce: ${nonce}`);
const auth = await account.authorize({
address: CONTRACT_ADDRESS,
nonce: nonce + 1,
});
const tx = await account.sendTransaction({
to: account.address,
type: 4,
authorizationList: [auth],
});
コード解除(Clear the account’s code)
以下のスクリプトでコードを解除できます。
const signers = await ethers.getSigners();
const account = signers[0];
const code = await ethers.provider.getCode(account.address);
console.log(`Account code: ${code}`);
if (code !== "0x") {
const nonce = await account.getNonce();
console.log(`Account nonce: ${nonce}`);
const auth = await account.authorize({
address: ethers.ZeroAddress,
nonce: nonce + 1,
});
const tx = await account.sendTransaction({
to: account.address,
type: 4,
authorizationList: [auth],
});
console.log(`Transaction hash: ${tx.hash}`);
const receipt = await tx.wait();
console.log(`Transaction receipt: ${receipt?.status}`);
const newCode = await ethers.provider.getCode(account.address);
console.log(`New account code: ${newCode}`);
} else {
console.log("Account code does not exist at the address.");
}
結果が以下となります。
Account code: 0xef0100b51e0d2aea249ee310dbb3a69e1c588819e2df09
Account nonce: 7
Transaction hash: 0xc9ac734e8997e159b42a54dd01ef6c8b9d88049659cbe3564ddbd42267073647
Transaction receipt: 1
New account code: 0x
EOA のアカウントに設定されているコードが空になります。
ポイント
ゼロアドレスに署名をすることで、設定ではなく解除となります。
const auth = await account.authorize({
address: ethers.ZeroAddress,
nonce: nonce + 1,
});
EIP-7702トランザクションの送信
EOA に設定されているコードを実行するトランザクションを送信します。
スポンサーなしトランザクション送信
1つのトランザクションで、2つのアカウントに送金してみます。
以下のコードでトランザクションを送信します。
const signers = await ethers.getSigners();
const account1 = signers[0];
const account2 = signers[1];
const account3 = signers[2];
await balanceOf(account1.address);
await balanceOf(account2.address);
await balanceOf(account3.address);
const code = await ethers.provider.getCode(account1.address);
console.log(`Account1 code: ${code}`);
if (code !== "0x") {
const contract = new ethers.Contract(
account1.address,
["function execute((address,uint256,bytes)[] calls) external payable"],
account1
);
const calls = [
[account2.address, ethers.parseEther("0.001"), "0x"],
[account3.address, ethers.parseEther("0.002"), "0x"],
];
const tx = await contract.execute(calls);
console.log(`Transaction hash: ${tx.hash}`);
const receipt = await tx.wait();
console.log(`Transaction receipt: ${receipt?.status}`);
await balanceOf(account1.address);
await balanceOf(account2.address);
await balanceOf(account3.address);
} else {
console.log("Account1 code does not exist at the address.");
}
結果は以下となります。
Balance of 0xCFb93e523f5D4f743F0Fd689F8055AB3F98A7293: 0.998550416189155344
Balance of 0x3C622bc19709b6153D3EFc545fF2D7582bfe407d: 0.0
Balance of 0x4b193691D4E1fc09A4da6093A5188417AB9728D5: 0.0
Account1 code: 0xef0100b51e0d2aea249ee310dbb3a69e1c588819e2df09
Transaction hash: 0xb38dd2b055deba75225739e1ae3dadd29382b92798cd4f943bf6a29557cbf32d
Transaction receipt: 1
Balance of 0xCFb93e523f5D4f743F0Fd689F8055AB3F98A7293: 0.995408208135636489
Balance of 0x3C622bc19709b6153D3EFc545fF2D7582bfe407d: 0.001
Balance of 0x4b193691D4E1fc09A4da6093A5188417AB9728D5: 0.002
Ethscan(Hoodi)でも確認ができます。
スポンサーありトランザクション送信
アカウント2からアカウント1のコードを実行して、2つのアカウントに送金してみます。
この場合、GAS代はアカウント2が支払うことになります。
以下のコードでトランザクションを送信します。
const signers = await ethers.getSigners();
const account1 = signers[0];
const account2 = signers[1];
const account3 = signers[2];
await balanceOf(account1.address);
await balanceOf(account2.address);
await balanceOf(account3.address);
const code = await ethers.provider.getCode(account1.address);
console.log(`Account1 code: ${code}`);
if (code !== "0x") {
const contract = new ethers.Contract(
account1.address,
[
"function execute((address,uint256,bytes)[] calls, bytes signature) external payable",
"function nonce() external view returns (uint256)",
],
account2
);
const calls = [
[account2.address, ethers.parseEther("0.001"), "0x"],
[account3.address, ethers.parseEther("0.002"), "0x"],
];
const nonce = await contract.nonce();
let encodedCalls = "0x";
for (const call of calls) {
const [to, value, data] = call;
encodedCalls += ethers
.solidityPacked(["address", "uint256", "bytes"], [to, value, data])
.slice(2);
}
const digest = ethers.keccak256(
ethers.solidityPacked(["uint256", "bytes"], [nonce, encodedCalls])
);
const signature = await account1.signMessage(ethers.getBytes(digest));
const tx = await contract.execute(calls, signature);
console.log(`Transaction hash: ${tx.hash}`);
const receipt = await tx.wait();
console.log(`Transaction receipt: ${receipt?.status}`);
await balanceOf(account1.address);
await balanceOf(account2.address);
await balanceOf(account3.address);
} else {
console.log("Account1 code does not exist at the address.");
}
結果は以下となります。
Balance of 0xCFb93e523f5D4f743F0Fd689F8055AB3F98A7293: 0.995408208135636489
Balance of 0x3C622bc19709b6153D3EFc545fF2D7582bfe407d: 0.001
Balance of 0x4b193691D4E1fc09A4da6093A5188417AB9728D5: 0.002
Account1 code: 0xef0100b51e0d2aea249ee310dbb3a69e1c588819e2df09
Transaction hash: 0x026bb0f6f9b813607ccf4e748eb3f7ee11380f1dd50c7c3f8f61c9a054bc0c6a
Transaction receipt: 1
Balance of 0xCFb93e523f5D4f743F0Fd689F8055AB3F98A7293: 0.992408208135636489
Balance of 0x3C622bc19709b6153D3EFc545fF2D7582bfe407d: 0.001924640624977985
Balance of 0x4b193691D4E1fc09A4da6093A5188417AB9728D5: 0.004
Ethscan(Hoodi)でも確認ができます。
ポイント
コールするコードにアカウント1の署名が必要です。
リプレイ対策(Replay protection)として、内部的に管理している、nonce
を使用しています。
const nonce = await contract.nonce();
let encodedCalls = "0x";
for (const call of calls) {
const [to, value, data] = call;
encodedCalls += ethers
.solidityPacked(["address", "uint256", "bytes"], [to, value, data])
.slice(2);
}
const digest = ethers.keccak256(
ethers.solidityPacked(["uint256", "bytes"], [nonce, encodedCalls])
);
const signature = await account1.signMessage(ethers.getBytes(digest));
まとめ
本記事では、EIP-7702を使用してEOAにコントラクトコードを設定し、実際にバッチトランザクションとスポンサード取引を実装することができました。
実現できたこと
- バッチトランザクション: 1つのトランザクションで複数の送金を実行
- スポンサード取引: 第三者がガス代を負担するトランザクション
- プログラマブルウォレット: EOAにスマートコントラクトの機能を追加
制約と注意点
ガス代の制約
EOAにコードを設定する際は、必ずそのEOAからのトランザクションが必要です。
そのため、完全なガスレス化は実現できず、少なくとも初回のコード設定時にはガス代が必要になります。
ストレージの永続化
コードの設定・解除を行っても、EOAのStorageはそのまま残存します。
コードを再設定する際は、前回設定したコントラクトのStorageが残っている可能性があるため、状態の初期化を適切に行う必要があります。
コントラクトインターフェースへの対応
EOAがコードを持つようになると、システムからコントラクトとして認識されるため、以下のようなERCインターフェースへの対応が必要になる場合があります:
今後の展望
EIP-7702は、従来のEOAの利便性を保ちながらスマートコントラクトの柔軟性を追加する画期的な機能です。
ただし、セキュリティ面での考慮事項や上記の制約を十分に理解した上で実装することが重要です。
アカウント抽象化の普及により、より使いやすく安全なWeb3体験の実現が期待されます。
参考