メインコンテンツへスキップ

ガスレスユーザーにトークンを保持させ、コントラクトを呼び出させる方法

ガスレス
erc-20
アカウント抽象化
中級
オリ・ポメランツ
2026年4月1日
28 分で読めます

はじめに

以前の記事では、EIP-712署名を使用して独自のアプリケーションにガスレスでアクセスする方法について説明しましたが、これは独自のスマート・コントラクトに限定されていました。アカウント抽象化を使用すると、2種類のトランザクションを受け入れ、要求された宛先に中継するスマート・コントラクトウォレットを作成できます。

  • 特定のEOAによって送信されたトランザクション(そのEOAがETHを持っている必要があります)
  • どこからでも送信できるが、同じEOAによって署名されたトランザクション。

このようにして、アカウントが資産(トークンなど)を保持し、ガスを持つEOAができるすべての機能を実行するためのガスレスな方法を提供できます。

なぜリクエストを単に中継できないのか?

ERC-20および関連する標準では、アカウントの所有者はmsg.sender (opens in a new tab)、つまりトークンコントラクトを呼び出したアドレスであり、必ずしもトランザクションの送信元であるtx.origin (opens in a new tab)とは限りません。これはセキュリティ上の理由 (opens in a new tab)から必要です。つまり、トークンの送金リクエストを中継すると、ユーザーが制御するアドレスからではなく、リレイヤーのアドレスからトークンを送金しようとします。

EIP-7702 (opens in a new tab)を介してEOAアドレスを使用できる解決策はありますが、潜在的に危険な委任に署名する必要があるため、ウォレットプロバイダーが承認するスマート・コントラクトにデリゲートする場合にのみ使用できます。このチュートリアルでは、ユーザーのプロキシとしてスマート・コントラクトを作成するという、はるかに簡単な方法を好みます。

実際の動作を見る

  1. Node (opens in a new tab)Foundry (opens in a new tab)の両方がインストールされていることを確認します。

  2. アプリケーションをクローンし、必要なソフトウェアをインストールします。

    git clone https://github.com/qbzzt/260315-gasless-tokens.git
    cd 260315-gasless-tokens
    forge build
    cd server
    npm install
    
  3. .envを編集して、SEPOLIA_PRIVATE_KEYをSepolia上にETHを持つウォレットに設定します。Sepolia ETHが必要な場合は、フォーセットを使用して取得してください。理想的には、この秘密鍵はブラウザのウォレットにあるものとは異なるものにする必要があります。

  4. サーバーを起動します。

    npm run dev
    
  5. URL http://localhost:5173 (opens in a new tab) でアプリケーションにアクセスします。

  6. Connect with Injectedをクリックしてウォレットに接続します。ウォレットで承認し、必要に応じてSepoliaへの変更を承認します。

  7. 下にスクロールして、**Deploy UserProxy (slow process)**をクリックします。

  8. UserProxy accessの横にアドレスが表示されるため、ユーザープロキシがいつデプロイされたかがわかります。24秒(2ブロック)待ってもまだ表示されない場合は、変更の検出に問題がある可能性があります。

    その場合は、Sepolia Explorer (opens in a new tab)にアクセスし、サーバー出力のnpm run devに表示されているデプロイメントのトランザクション・ハッシュを入力します。作成されたコントラクトをクリックしてそのアドレスを表示し、コピーします。_Or enter existing proxy address_フィールドにアドレスを貼り付け、Set proxy addressをクリックします。

  9. Request more tokens for proxyをクリックして、ERC-20コントラクトのfaucet (opens in a new tab)関数への呼び出しを送信し、トークンを取得します。ウォレットで署名を確認します。もちろん、トークンはユーザーのアドレスではなく、プロキシのアドレスに届きます。

  10. 下にスクロールして、_Last transaction:_の下にあるリンクをクリックします。これによりブラウザが開き、faucetトランザクションが表示されます。

  11. amount to transferに、1から1000までの数字を入力します。Transferをクリックして、トークンを自分のアドレスに送金します。リクエストのConfirmをクリックする前に、署名されるデータが不透明であることを確認してください。ユーザーは自分が何に署名しているのかを理解するのに苦労するでしょう。これについては後述することを覚えておいてください。

  12. トランザクションが確認された後、_your balance_と_proxy balance_の両方の変更が表示されるのを待ちます。Sepoliaのブロックタイムは12秒であるため、これにも少し時間がかかることに注意してください。

仕組み

ガスレスな体験を提供するには、ユーザー向けのユーザーインターフェース、ユーザーインターフェースからチェーンにメッセージをルーティングするサーバー、そしてそれらを受信して検証するスマート・コントラクトが必要です。

ウォレットのスマート・コントラクト

これがスマート・コントラクト (opens in a new tab)です。その目的は、リクエストに使用されたチャネルに関係なく、実際の所有者がリクエストしたことを何でも実行し、それ以外はすべて無視することです。これを行うために、その関数は呼び出すターゲットアドレスと、それを呼び出すために使用するデータを受け取ります。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract UserProxy {
    address immutable OWNER;
    uint public nonce = 0;

所有者の身元と、メッセージの繰り返しを防ぐためのナンス (opens in a new tab)です。ナンスはpublic変数であるため、Solidityコンパイラは、オフチェーンのコードがその値を読み取れるようにするビュー関数nonce() (opens in a new tab)も作成します。

    bytes32 private constant SIGNED_ACCESS_TYPEHASH =
        keccak256("SignedAccess(address target,bytes data,uint256 nonce)");

    bytes32 private constant SIGNED_ACCESS_PAYABLE_TYPEHASH =
        keccak256("SignedAccessPayable(address target,bytes data,uint256 nonce,uint256 value)");

    bytes32 immutable DOMAIN_SEPARATOR;

EIP-712署名 (opens in a new tab)を検証するために必要な情報です。

    constructor(address owner_) {
        OWNER = owner_;

UserProxyは単一の所有者アドレスに結び付けられています。これは、資産(ERC-20トークン、NFTなど)を所有できるため必要です。異なる所有者に属する資産を混在させたくありません。

ドメインセパレーター (opens in a new tab)です。チェーンIDとコントラクトアドレスに依存するため、コンパイル時に計算することはできません。これにより、UserProxyが別のプロキシ用に準備されたメッセージに騙されることは不可能になります。

    event CallResult(address target, bytes returnData);

呼び出しの結果をログに記録します。

    function directAccess(address target, bytes calldata data)
            external returns (bytes memory) {

この関数は、所有者が直接呼び出すことができます。リレイヤーが利用できない場合でも、所有者はブロックチェーン上の資産に直接アクセスできます(ユーザーがETHを持っている場合)。

        require(msg.sender == OWNER, "Only owner can call");
        (bool success, bytes memory returnData) = target.call(data);
        require(success, "Call failed");

        emit CallResult(target, returnData);

        return returnData;
    }

所有者から_直接_呼び出された場合は、提供されたコールデータを使用してターゲットを呼び出します。

    function signedAccess(
        address target,
        bytes calldata data,
        uint8 v,
        bytes32 r,
        bytes32 s)

これがUserProxyのメイン関数です。targetdata、および署名を取得します。

ダイジェストにはナンスも含まれますが、トランザクションから受け取る必要はありません。正しい値はすでにわかっています。間違ったナンスを持つ署名は拒否されます。


    // 署名者を復元する
    address signer = ecrecover(digest, v, r, s);
    require(signer == OWNER, "Signature invalid or not by owner");

署名が無効な場合、ecrecoverは通常異なるアドレスを返し、受け入れられません。

    (bool success, bytes memory returnData) = target.call(data);
    require(success, "Call failed");

ユーザーが呼び出すように指示したコントラクトを呼び出し、成功しなかった場合はリバートします。

    emit CallResult(target, returnData);

    nonce++; // リプレイを防ぐためにナンスをインクリメントする

    return returnData;
}

成功した場合は、ログイベントを発行し、ナンスをインクリメントします。

これらはほぼ同一のバリアントであり、コントラクトからETHを送金することもできます。

リレイヤー

リレイヤーはサーバーコンポーネントです。JavaScriptで書かれています。ソースコードはこちら (opens in a new tab)で確認できます。

import express from "express";
import { createServer as createViteServer } from "vite";
import { createWalletClient, createPublicClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import dotenv from 'dotenv'

必要なライブラリです。これはExpress (opens in a new tab)サーバーであり、Vite (opens in a new tab)を使用してユーザーインターフェースのコードを提供します。ブロックチェーンとの通信にはViem (opens in a new tab)を使用し、トランザクションを送信するアドレスの秘密鍵を読み取るためにdotenv (opens in a new tab)を使用します。

import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const UserProxy = require('../contracts/out/UserProxy.sol/UserProxy.json')

これはコンパイルされたUserProxyを読み取る簡単な方法です。UserProxyを呼び出せるようにするためにABIが必要であり、ユーザーのためにデプロイできるようにするためにコンパイルされたコードが必要です。

dotenv.config()
const sepoliaAccount = privateKeyToAccount(process.env.SEPOLIA_PRIVATE_KEY)
console.log("Using account:", sepoliaAccount.address)

.envファイルを読み取り、アドレスを抽出してコンソールに出力します。

ブロックチェーンと通信するViemクライアントです。

const start = async () => {
  const app = express()

Expressサーバーを実行します。

  app.use(express.json())

リクエストボディを読み取り、JSONであればパースするようにExpressに指示します。

  app.post("/server/deploy", async (req, res) => {

これはプロキシをデプロイするリクエストを処理するコードです。攻撃者が私たちのETHが枯渇するまでプロキシのデプロイリクエストをスパム送信できるため、ここではサービス拒否(DoS) (opens in a new tab)攻撃に対して脆弱であることに注意してください。本番システムでは、おそらくプロキシのデプロイリクエストが署名されていること、および署名者が既存の顧客であることを要求するでしょう。

    try {
      const ownerAddress = req.body.ownerAddress

リクエストから所有者のアドレスを取得します。

コントラクトをデプロイ (opens in a new tab)し、デプロイされるまで待ちます (opens in a new tab)

      res.json({ contractAddress: receipt.contractAddress })

すべて問題なければ、プロキシアドレスをユーザーインターフェースに返します。

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

問題がある場合は報告します。

  app.post("/server/message", async (req, res) => {

これはUserProxyコントラクトのユーザーメッセージを処理するコードです。これもサービス拒否攻撃に対して脆弱なポイントです。

リクエストデータを取得し、それを使用してプロキシ上のsignedAccessを呼び出します。

      console.log("Message transaction hash:", txHash)

      res.json({ txHash })

トランザクション・ハッシュを報告します。これにより、UIはユーザーがトランザクションを確認するためのURLを表示できます。

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

繰り返しになりますが、問題がある場合は報告します。

それ以外の場合は、ユーザーインターフェースの提供を処理するViteを使用します。

ユーザーインターフェース

これがユーザーインターフェースのコードです (opens in a new tab)。コードの大部分は、Token.jsx (opens in a new tab)を除いて、この記事で文書化されているものとほぼ同じです。

Token.jsx (opens in a new tab)の一部は、この記事Greeter.jsx (opens in a new tab)に似ています。以下は新しい部分です。

import {
   encodeFunctionData
       } from 'viem'

この関数 (opens in a new tab)は、EVM関数呼び出しのコールデータを作成します。これは、ユーザーがコールデータに署名できるようにするために必要です。

import UserProxy from '../../contracts/out/UserProxy.sol/UserProxy.json'

上記で説明したUserProxyです。

import Erc20 from '../../contracts/out/Faucet.sol/FaucetToken.json'

このコントラクト (opens in a new tab)は、1つの重要な関数faucet()が追加されていることを除けば、ほとんど通常のERC-20コントラクトです。この関数は、テスト目的で要求した人にトークンを付与します。

const erc20Addrs = {
  // Sepolia
    11155111: '0x4cBedDEDA88fDd9e116618a5cD71BB0E440C2A78'
}

FaucetTokenのアドレスです。

const Address = ({ address }) => {
   if (!address) return null
   return (
      <a href={`https://eth-sepolia.blockscout.com/address/${address}?tab=read_write_contract`} target="_blank">{address}</a>
   )
}

このコンポーネントは、ブロック・エクスプローラー上のコントラクトへのリンクを含むアドレスを出力します。

const Token = () => {
    ...

これがほとんどの作業を行うメインコンポーネントです。

  const [ balanceAmount, setBalanceAmount ] = useState("Loading...")

ユーザーアドレスのトークン残高です。

  const [ proxyAddr, setProxyAddr ] = useState(null)

ユーザーが所有するプロキシのアドレスです。

  const [ proxyBalanceAmount, setProxyBalanceAmount ] = useState("Loading...")

プロキシのトークン残高です。

  const [ newProxyAddr, setNewProxyAddr ] = useState("")

このフィールドは、ユーザーが手動でプロキシアドレスを設定する場合に使用されます。プロキシアドレスを手動で設定できることで、ユーザーは毎回新しいプロキシをデプロイする(そして古いプロキシが所有するすべてのトークンを失う)代わりに、既存のプロキシを使用できます。

  const [ txHash, setTxHash ] = useState(null)

最後のトランザクションのハッシュです。ユーザーがそのトランザクションを確認できるように、エクスプローラーへのリンクを表示するために使用されます。

  const [ transferToken, setTransferToken ] = useState("")
  const [ transferAmount, setTransferAmount ] = useState("")
  const [ transferTo, setTransferTo ] = useState("")

これらのフィールドはすべて、ERC-20コントラクトにトークン送金コマンドを送信するために使用されます。これはFaucetTokenである可能性がありますが、そうである必要はありません。transfer関数はERC-20標準の一部です。

  const balance = useReadContract({
    ...
  })


  const proxyBalance = useReadContract({
    ...
  })

関心のある2つのトークン残高、つまりユーザーがどれだけ所有しているか、プロキシがどれだけ所有しているかを読み取ります。

  const nonce = useReadContract({
      address: proxyAddr,
      abi: UserProxy.abi,
      functionName: 'nonce',
      args: [],
  })

リプレイ攻撃(たとえば、売り手がお金を受け取るトランザクションをリプレイするなど)を防ぐために、ナンス (opens in a new tab)を使用します。署名するデータに追加するために、現在の値を知る必要があります。

ブロックチェーンから読み取った情報が変更されたときにユーザーに表示される残高を更新するには、useEffect (opens in a new tab)を使用します。

  useEffect(() => {
    setTransferToken(faucetAddr)
  }, [faucetAddr])

  useEffect(() => {
    setTransferTo(account.address)
  }, [account.address])

デフォルトでは、FaucetTokenトークンをユーザー自身のアカウントに送金します。ここでは、Viemから受け取ったときにこれらの値を設定します。

  const proxyAddressChange = (evt) => setNewProxyAddr(evt.target.value)
  const transferTokenChange = (evt) => setTransferToken(evt.target.value)
  const transferToChange = (evt) => setTransferTo(evt.target.value)
  const transferAmountChange = (evt) => setTransferAmount(evt.target.value)

テキストフィールドが変更されたときのイベントハンドラーです。

このユーザーのためにプロキシをデプロイするようにサーバーに要求します。

  const signMessage = async(proxyAddr, target, calldata) => {

オンチェーンのUserProxyに送信するためにサーバーに送信する前に、メッセージに署名します。これについてはこちらで説明されています。ターゲットアドレス(呼び出しているトークンのアドレス)と送信するコールデータの両方を含むメッセージに署名する必要があります。

    const domain = {
      .
      .
      .
    return {v, r, s}
  }

  const messageUserProxy = async (proxy, target, data, v, r, s) => {

署名されたメッセージをUserProxyに送信します。これにより署名が検証され、targetに送信されます。

サーバーにリクエストを送信し、レスポンスを受け取ったらトランザクション・ハッシュを取得します。

  const faucetSimulation = useSimulateContract({
    address: faucetAddr,
    abi: Erc20.abi,
    functionName: 'faucet',
    account: account.address
  })

faucet関数の呼び出しをシミュレートします。これが成功した場合にのみ、フォーセットボタンを有効にします。

サーバーとUserProxyを介して関数を呼び出すには、次の3つの手順に従います。

  1. encodeFunctionData (opens in a new tab)を使用して、署名して送信するコールデータを作成します。

  2. メッセージ(ターゲットアドレス、コールデータ、およびナンス)に署名します。

  3. メッセージをサーバーに送信します。

コンポーネントのこの部分では、ブラウザから直接FaucetTokenを使用できます。その主な目的はデバッグを容易にすることです。

         <h4>UserProxy access <Address address={proxyAddr} /></h4>
         <button onClick={deployUserProxy}>
         Deploy UserProxy (slow process)
         </button>

ユーザーに新しいUserProxyをデプロイさせます。

正当なアドレスを入力した場合にのみ、ユーザーがSet proxy addressをクリックできるようにします。これにより、問題のアドレスが実際にUserProxyコントラクトであることが保証されるわけではないことに注意してください。そのようなチェックを追加することは可能ですが、はるかに遅くなり(ユーザーエクスペリエンスが悪化し)、セキュリティは向上しません(攻撃者は常にユーザーインターフェースに独自のコードを使用できます)。

         <br /><br />
         { proxyAddr && (

正当なプロキシアドレスがある場合に_のみ_、残りを表示します。

            <>
               Proxy balance: {proxyBalanceAmount}
               <br />
               Proxy nonce: {nonce?.data?.toString() ?? "Loading..."}

ユーザーはナンスを知る必要はありません。これは単なるデバッグ目的です。

               <br />
               <button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
                  onClick={proxyFaucet}
               >
                  Request more tokens for proxy
               </button>

プロキシを介したfaucet()への呼び出しをシミュレートすることはできません。ただし、少なくともプロキシがあり、プロキシがナンスを報告したことは確認できます。

ユーザーにERC-20の送金トランザクションを発行させます。

最後のトランザクション・ハッシュがある場合は、ユーザーがブロック・エクスプローラーで表示できるようにリンクを表示します。

 
</div>
    </>
  )
}

export {Token}

これは単なるReactのボイラープレートです。

脆弱性

私たちのサーバーはサービス拒否攻撃に対して脆弱です。この攻撃については、シリーズの前の記事で説明されています。

さらに、私たちはユーザーの悪い行動を助長しています。これがユーザーに署名を求めているものです。

Screen capture with opaque calldata

私たちは、これがユーザーが送金したいトークン、金額、および宛先アドレスに対する正当なERC-20の送金であることを知っています。しかし、ほとんどのユーザーはコールデータの解釈方法を知らず、自分が何に署名しているのかまったくわかりません。これは2つの理由から悪い設計です。

  • 署名するように指示されたデータを信頼できないため、私たちを使用しないユーザーもいます。
  • 他のユーザーは私たちを_信頼し_、それが何であるかを理解せずにコールデータに署名するだけでよいと学習します。つまり、攻撃者のアダムが彼らを自分のウェブサイトにリダイレクトすることに成功した場合、ユーザーが所有するすべてのUSDC(またはDAI、その他のERC-20)を彼に付与するトランザクションに署名させることができます。

解決策は、送金などの一般的に使用される関数用に、UserProxyに個別の関数を用意することです。そうすれば、ユーザーは自分が理解できるものに署名できます。

Screen capture with transfer details

注: ユーザーは好きなウォレットを使用できますが、EIP-712を使用するアプリケーションでは、署名データ全体を表示する (opens in a new tab)ウォレットを使用することを強くお勧めします。一部のウォレットはアドレスを切り詰めますが、これは安全ではありません。攻撃者は、最初と最後の文字が同じで、真ん中が異なるアドレスを作成できます。

Screen capture with truncated addresses

結論

上記の脆弱性に加えて、このチュートリアルの解決策には、イーサリアムが対処するのに役立ついくつかの欠点があります。

  • 検閲耐性。現在、ユーザーはあなたのサーバー、他の誰かが設定した競合するサーバーを使用するか、イーサリアムに直接接続することができますが、これにはガスコストがかかります。ERC-4337 (opens in a new tab)を使用すると、ユーザーはトランザクションをサーバーの大規模なプールに提供できるため、トランザクションが検閲される可能性が低くなります。
  • EOAが所有する資産。上記のように、EIP-7702 (opens in a new tab)を使用して、EOAアドレスがすでに所有している資産を管理できます。これには困難が伴いますが、必要な場合もあります。

近い将来、これらの機能の追加に関するチュートリアルを公開したいと考えています。

私の他の作品についてはこちらをご覧ください (opens in a new tab)