💠

Multicall3を使ってERC-20のapproveとtransferFromを1回のトランザクションで実行する

に公開

はじめに

スマートコントラクトでアプリケーションを開発する際、「トークンを受け取ったら何か処理を行う」というユースケースはよくあります。
しかし、ERC-20transfer関数だけでは、アプリケーションのコントラクトを起動できません。

そのため、まずユーザーがapproveでトークン移転の許可を与え、その後アプリケーションのコントラクトがtransferFromを使ってトークンを受け取るというフローが必要になります。

従来、(ERC-1363のような実装がない)純粋なERC-20トークンでは、上記フローの1.2.の操作は別々のトランザクションとして実行する必要がありました。
しかし、EIP-7702の導入により1回のトランザクション送信で実現可能になったので本記事ではその実装方法を紹介します。

対象読者

  • Solidityの基礎知識がある方

EIP-7702の概要

EIP-7702は、EOAが一時的にスマートコントラクトのコードを実行できるようにする提案で、2025年5月7日のPectraアップグレードで導入されました。

今回は、このデリゲート先コントラクトにバッチ処理可能なコントラクトを指定することで目的を達成します。

実装

ここからはコードを使って具体的な実装方法を説明します。

アプリケーションコントラクト

まず、アプリケーションコントラクトを定義します。(DAppsが利用するコントラクトという想定です。)
ここでは、アプリケーションとしての処理は省略し、指定したERC-20トークンからtransferFromでトークンを受け取る処理のみを実装します。

TransferFromExample.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract TransferFromExample {
    using SafeERC20 for IERC20;

    function run(address erc20Address, uint256 amount) external {
        IERC20(erc20Address).safeTransferFrom(msg.sender, address(this), amount);
        // ここでトークンを受け取った後の処理を実行(省略)
    }
}

これをSepoliaテストネットにデプロイしました。コントラクトアドレスは0xAfcC2c61CB9cf4b8383cD458765F5d849D2e4C71です。

https://sepolia.etherscan.io/address/0xafcc2c61cb9cf4b8383cd458765f5d849d2e4c71

バッチ処理コントラクト

次はデリゲート先となるコントラクトです。今回はバッチ処理を実行したいので、Multicall3コントラクトを利用します。
Multicall3の詳細はGitHubリポジトリをご覧ください。

https://github.com/mds1/multicall3

コントラクトアドレスは以下のページで確認できます。Sepoliaテストネットには0xcA11bde05977b3631167028862bE2a173976CA11でデプロイされています。

https://www.multicall3.com/deployments

ERC-20コントラクト

最後はERC-20トークンコントラクトです。こちらも既存のものを使用します。今回はSepoliaテストネットのUSDCトークンを使います。
コントラクトアドレスは0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238です。

USDCトークンは以下のサイトから無料で入手できます。

https://faucet.circle.com/

トランザクション送信スクリプト

必要なコントラクトが揃ったので、実際にapprovetransferFromを1回のトランザクションで実行するスクリプトを作成していきます。

approve-and-transferFrom.ts
import { ethers, Wallet, Interface, AddressLike, BytesLike, JsonRpcProvider } from "ethers";
import dotenv from "dotenv";
import { TransferFromExample__factory } from "../typechain-types";

// Multicall3のコントラクトアドレス。EOAのデリゲート先として使用。
const SEPOLIA_MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";

// USDCコントラクトアドレス
const SEPOLIA_USDC_ADDRESS = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
// 転送するUSDCの量
const USDC_AMOUNT = 10_000n; // 0.01 USDC (USDCのdecimalsは6)

// デプロイしたTransferFromExampleコントラクト(アプリケーションコントラクト)のアドレス
const TRANSFER_FROM_EXAMPLE_ADDRESS = "0xAfcC2c61CB9cf4b8383cD458765F5d849D2e4C71";


(async () => {
    // .envファイル読み込み
    // - PRIVATE_KEY: EOAアカウントの秘密鍵
    // - SEPOLIA_RPC_URL: SepoliaネットワークのRPC URL
    dotenv.config();

    const runner = new Wallet(process.env.PRIVATE_KEY!, new JsonRpcProvider(process.env.SEPOLIA_RPC_URL!)); // EOAアカウント

    const erc20Interface = new Interface([
        "function approve(address spender, uint256 amount) external returns (bool)"
    ]);
    const transferFromExampleInterface = TransferFromExample__factory.createInterface();

    const approveData = erc20Interface.encodeFunctionData("approve", [TRANSFER_FROM_EXAMPLE_ADDRESS, USDC_AMOUNT]);
    const runData = transferFromExampleInterface.encodeFunctionData("run", [SEPOLIA_USDC_ADDRESS, USDC_AMOUNT]);

    // Multicall3のaggregate3関数に渡すcallsデータを作成
    const calls: { target: AddressLike; allowFailure: boolean; callData: BytesLike; }[] = [
        {
            target: SEPOLIA_USDC_ADDRESS,
            allowFailure: false,
            callData: approveData
        },
        {
            target: TRANSFER_FROM_EXAMPLE_ADDRESS,
            allowFailure: false,
            callData: runData
        }
    ];

    // 委譲認可を作成(EIP-7702)
    const auth = await runner.authorize({
        address: SEPOLIA_MULTICALL3_ADDRESS,
        nonce: (await runner.provider!.getTransactionCount(runner.address)!) + 1
    });

    const multicall3Interface = new Interface([
        "function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) payable returns (tuple(bool success, bytes returnData)[] returnData)"
    ]);
    const delegatedContract = new ethers.Contract(
        runner.address,
        multicall3Interface,
        runner
    );

    // トランザクション送信
    const tx = await delegatedContract.aggregate3(calls, {
        type: 4,
        authorizationList: [auth],
    });

    console.log("Transaction Hash:", tx.hash);
    await tx.wait();
    console.log("Transaction confirmed.");
})();

処理フローは以下のようになります。

実際にこのスクリプトを実行したときのトランザクションがこちらです。
トランザクション送信元アドレスは0xA611Fd621C94af6211FFCd115c727c1CAf221D82です。

https://sepolia.etherscan.io/tx/0x1507ce6e2f270745e3156908c522a7512fc5f3ca1e2b6f3d1bcb0ca3f008d229

次の画像ではデリゲート先アドレスがMulticall3コントラクトアドレスになっていることがわかります。
また、EIP-7702で導入されたトランザクションタイプ4が使われていることが記録されていることが確認できます。

"トランザクションtypeが4"

こちらの画像では0.01 USDCがアプリケーションコントラクトに転送されていることがわかります。
また、LogsタブではapprovetransferFromで発生するイベント(ApprovalTransfer)が1つのトランザクションに含まれていることも確認できます。

"0.01 USDCが転送されている様子"

"approveとtransferFromが1回のトランザクションで実行されている様子"

なぜMulticall3だけでは実現できなかったのか

Multicall3は複数のコントラクト呼び出しを1回のトランザクションで実行できます。
そのため、EIP-7702導入前でもapprovetransferFromを1回のトランザクションで実現できそうに見えます。

しかし、単にMulticall3aggregate3関数を使った場合、approve呼び出し時のmsg.senderMulticall3コントラクトのアドレスになってしまいます。
そのため、approveの処理は意図しないものとなり、通常は失敗します。

一方、EIP-7702の機能を使うと、Multicall3コントラクトのコードを一時的にEOAのコードに設定して実行するため、msg.senderEOAのアドレスとなり、意図した通りに動作します。

MetaMaskなどのウォレットでもできるのか

先ほどのコードでは、秘密鍵を使ってethers.jsWalletオブジェクトを作成し、authorizeメソッドで委譲認可を生成していました。

    const runner = new Wallet(process.env.PRIVATE_KEY!, new JsonRpcProvider(process.env.SEPOLIA_RPC_URL!)); // EOAアカウント

    ...
    
    // 委譲認可を作成(EIP-7702)
    const auth = await runner.authorize({
        address: SEPOLIA_MULTICALL3_ADDRESS,
        nonce: (await runner.provider?.getTransactionCount(runner.address)!) + 1
    });

では、MetaMaskなどのウォレットを使う場合はどうでしょうか。
以下のように記述すればよさそうですが、実際に実行しようとするとエラーになります。(2025年10月時点)

    const provider = new BrowserProvider(window.ethereum);
    const signer = await provider.getSigner();

    ...

    // 委譲認可を作成(EIP-7702)
    const auth = await signer.authorize({ // ここでエラーになる
        address: SEPOLIA_MULTICALL3_ADDRESS,
        nonce: (await provider.getTransactionCount(signer.getAddress())) + 1
    });
# ethers.js v6でのエラー表示
authorization not implemented for this signer (operation="authorize", code=UNSUPPORTED_OPERATION, version=6.15.0)

EIPの記述によると、ウォレットは委譲認可を作成のUIを提供すべきでないとあるので、今後も上記コードが動作することは無いと考えるのが妥当でしょう。

While this EIP provides a lot of flexibility to applications and EOAs, there are incorrect ways of using it. Applications must not expect that they can suggest the user sign an authorization, and therefore it is the duty of the wallet to not provide an interface to do so.
There is no safe way to provide this interface. The code specified by an authorization has unrestricted access to the account and must always be closely audited by the wallet. Few users have the level of sophistication to reasonably verify the code they are delegating to.

ChatGPTによる和訳

このEIPはアプリケーションやEOAに多くの柔軟性を提供しますが、誤った使用方法も存在します。
アプリケーションは「ユーザーに署名付き認可を求めることができる」と想定してはなりません。したがって、そのような操作を行うインターフェースを提供しないようにすることはウォレットの責務です。
このインターフェースを安全に提供する方法は存在しません。
認可によって指定されたコードはアカウントへの無制限なアクセス権を持つため、ウォレットによって常に厳密に監査されなければなりません。
自分が委任しようとしているコードを適切に検証できるだけの知識を持つユーザーはほとんどいません。

https://eips.ethereum.org/EIPS/eip-7702#interaction-with-applications-and-wallets

これらから、MetaMaskのようなウォレットではEIP-7702の機能を使ってapprovetransferFromを1回のトランザクションで実行できないように見えますが、別の方法で実現可能です。

その方法については、また別の記事で紹介します。

Discussion