ガス代のスポンサー: ユーザーのトランザクションコストを負担する方法
はじめに
イーサリアムがさらに10億人の人々 (opens in a new tab)に利用されるようにするには、摩擦を取り除き、可能な限り使いやすくする必要があります。この摩擦の原因の1つは、ガス代を支払うためにETHが必要になることです。
ユーザーから収益を得る分散型アプリケーション (dapp) がある場合、ユーザーにサーバー経由でトランザクションを送信させ、トランザクション手数料を自分で支払うのが理にかなっているかもしれません。ユーザーは依然としてウォレットでEIP-712承認メッセージ (opens in a new tab)に署名するため、イーサリアムの完全性の保証は維持されます。可用性はトランザクションを中継するサーバーに依存するため、より制限されます。ただし、ユーザーが (ETHを入手した場合に) スマート・コントラクトに直接アクセスできるように設定したり、他の人がトランザクションのスポンサーになりたい場合に独自のサーバーを設定できるようにしたりすることも可能です。
このチュートリアルの手法は、スマート・コントラクトを制御している場合にのみ機能します。他のスマート・コントラクトへのトランザクションをスポンサーできるアカウント抽象化 (opens in a new tab)などの他の手法もありますが、これについては将来のチュートリアルで取り上げたいと思います。
注意: これは本番レベルのコードでは_ありません_。重大な攻撃に対して脆弱であり、主要な機能が欠けています。詳細については、このガイドの脆弱性セクションをご覧ください。
前提条件
このチュートリアルを理解するには、以下の知識が必要です。
- Solidity
- JavaScript
- ReactとWAGMI。これらのユーザーインターフェースツールに慣れていない場合は、そのためのチュートリアルがあります。
サンプルアプリケーション
ここでのサンプルアプリケーションは、HardhatのGreeterコントラクトの変種です。GitHubで (opens in a new tab)確認できます。スマート・コントラクトはすでにSepolia (opens in a new tab)のアドレス0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab)にデプロイされています。
実際の動作を確認するには、以下の手順に従ってください。
-
リポジトリをクローンし、必要なソフトウェアをインストールします。
1git clone https://github.com/qbzzt/260301-gasless.git2cd 260301-gasless/server3npm install -
.envを編集して、PRIVATE_KEYをSepoliaにETHを持つウォレットに設定します。SepoliaのETHが必要な場合は、フォーセットを使用してください。理想的には、この秘密鍵はブラウザのウォレットにあるものとは異なるものにする必要があります。 -
サーバーを起動します。
1npm run dev -
URL
http://localhost:5173(opens in a new tab) でアプリケーションにアクセスします。 -
Connect with Injectedをクリックしてウォレットに接続します。ウォレットで承認し、必要に応じてSepoliaへの変更を承認します。
-
新しい挨拶を書き込み、Update greeting via sponsorをクリックします。
-
メッセージに署名します。
-
約12秒 (Sepoliaのブロックタイム) 待ちます。待っている間、サーバーのコンソールにあるURLを見てトランザクションを確認できます。
-
挨拶が変更され、最後に更新したアドレスの値がブラウザのウォレットのアドレスになっていることを確認します。
これがどのように機能するかを理解するには、ユーザーインターフェースでメッセージがどのように作成され、サーバーによってどのように中継され、スマート・コントラクトがそれをどのように処理するかを見る必要があります。
ユーザーインターフェース
ユーザーインターフェースはWAGMI (opens in a new tab)に基づいています。詳細についてはこのチュートリアルで読むことができます。
メッセージに署名する方法は次のとおりです。
1const signGreeting = useCallback(ReactフックのuseCallback (opens in a new tab)を使用すると、コンポーネントが再描画されるときに同じ関数を再利用することでパフォーマンスを向上させることができます。
1 async (greeting) => {2 if (!account) throw new Error("Wallet not connected")アカウントがない場合は、エラーを発生させます。この場合、signGreetingを呼び出すプロセスを開始するUIボタンが無効になっているため、これは決して起こらないはずです。ただし、将来のプログラマーがその安全装置を削除する可能性があるため、ここでもこの条件を確認することをお勧めします。
1 const domain = {2 name: "Greeter",3 version: "1",4 chainId,5 verifyingContract: contractAddr,6 }ドメインセパレータ (opens in a new tab)のパラメータです。この値は定数であるため、より最適化された実装では、関数が呼び出されるたびに再計算するのではなく、1回だけ計算するかもしれません。
nameは、署名を生成しているdappの名前など、ユーザーが読める名前です。versionはバージョンです。異なるバージョンには互換性がありません。chainIdは、WAGMIによって (opens in a new tab)提供される、使用しているチェーンです。verifyingContractは、この署名を検証するコントラクトのアドレスです。複数のGreeterコントラクトがあり、それぞれに異なる挨拶を持たせたい場合に備えて、同じ署名が複数のコントラクトに適用されないようにします。
1
2 const types = {3 GreetingRequest: [4 { name: "greeting", type: "string" },5 ],6 }署名するデータ型です。ここでは単一のパラメータgreetingがありますが、実際のシステムでは通常もっと多くなります。
1 const message = { greeting }署名して送信したい実際のメッセージです。greetingはフィールド名であり、それを埋める変数の名前でもあります。
1 const signature = await signTypedDataAsync({2 domain,3 types,4 primaryType: "GreetingRequest",5 message,6 })実際に署名を取得します。ユーザーがデータに署名するのには (コンピュータの観点から) 長い時間がかかるため、この関数は非同期です。
1 const r = `0x${signature.slice(2, 66)}`2 const s = `0x${signature.slice(66, 130)}`3 const v = parseInt(signature.slice(130, 132), 16)4
5 return {6 req: { greeting },7 v,8 r,9 s,10 }11 },この関数は単一の16進数値を返します。ここではそれをフィールドに分割します。
1 [account, chainId, contractAddr, signTypedDataAsync],2)これらの変数のいずれかが変更された場合は、関数の新しいインスタンスを作成します。accountとchainIdのパラメータは、ユーザーがウォレットで変更できます。contractAddrはチェーンIDの関数です。signTypedDataAsyncは変更されないはずですが、フックから (opens in a new tab)インポートしているため確実ではなく、ここに追加するのが最善です。
新しい挨拶に署名したので、それをサーバーに送信する必要があります。
1 const sponsoredGreeting = async () => {2 try {この関数は署名を受け取り、サーバーに送信します。
1 const signedMessage = await signGreeting(newGreeting)2 const response = await fetch("/server/sponsor", {アクセス元のサーバーのパス/server/sponsorに送信します。
1 method: "POST",2 headers: { "Content-Type": "application/json" },3 body: JSON.stringify(signedMessage),4 })POSTを使用して、情報をJSONエンコードして送信します。
1 const data = await response.json()2 console.log("Server response:", data)3 } catch (err) {4 console.error("Error:", err)5 }6 }レスポンスを出力します。本番システムでは、ユーザーにもレスポンスを表示します。
サーバー
私はフロントエンドとしてVite (opens in a new tab)を使用するのが好きです。Reactライブラリを自動的に提供し、フロントエンドのコードが変更されるとブラウザを更新します。ただし、Viteにはバックエンドのツールは含まれていません。
解決策はindex.js (opens in a new tab)にあります。
1 app.post("/server/sponsor", async (req, res) => {2 ...3 })4
5 // その他のすべてはViteに任せる6 const vite = await createViteServer({7 server: { middlewareMode: true }8 })9
10 app.use(vite.middlewares)まず、自分たちで処理するリクエスト (/server/sponsorへのPOST) のハンドラーを登録します。次に、Viteサーバーを作成して使用し、他のすべてのURLを処理します。
1 app.post("/server/sponsor", async (req, res) => {2 try {3 const signed = req.body4
5 const txHash = await sepoliaClient.writeContract({6 address: greeterAddr,7 abi: greeterABI,8 functionName: 'sponsoredSetGreeting',9 args: [signed.req, signed.v, signed.r, signed.s],10 })11 } ...12 })これは単なる標準的なviem (opens in a new tab)のブロックチェーン呼び出しです。
スマート・コントラクト
最後に、Greeter.sol (opens in a new tab)は署名を検証する必要があります。
1 constructor(string memory _greeting) {2 greeting = _greeting;3
4 DOMAIN_SEPARATOR = keccak256(5 abi.encode(6 keccak256(7 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"8 ),9 keccak256(bytes("Greeter")),10 keccak256(bytes("1")),11 block.chainid,12 address(this)13 )14 );15 }コンストラクタは、上記のユーザーインターフェースのコードと同様に、ドメインセパレータ (opens in a new tab)を作成します。ブロックチェーンの実行ははるかにコストがかかるため、1回だけ計算します。
1 struct GreetingRequest {2 string greeting;3 }これが署名される構造体です。ここではフィールドが1つだけあります。
1 bytes32 private constant GREETING_TYPEHASH =2 keccak256("GreetingRequest(string greeting)");これは構造体識別子 (opens in a new tab)です。ユーザーインターフェースで毎回計算されます。
1 function sponsoredSetGreeting(2 GreetingRequest calldata req,3 uint8 v,4 bytes32 r,5 bytes32 s6 ) external {この関数は署名されたリクエストを受け取り、挨拶を更新します。
1 // EIP-712ダイジェストを計算する2 bytes32 digest = keccak256(3 abi.encodePacked(4 "\x19\x01",5 DOMAIN_SEPARATOR,6 keccak256(7 abi.encode(8 GREETING_TYPEHASH,9 keccak256(bytes(req.greeting))10 )11 )12 )13 );EIP-712 (opens in a new tab)に従ってダイジェストを作成します。
1 // 署名者を復元する2 address signer = ecrecover(digest, v, r, s);3 require(signer != address(0), "Invalid signature");ecrecover (opens in a new tab)を使用して署名者のアドレスを取得します。不正な署名でも有効なアドレス (ただしランダムなもの) が得られる可能性があることに注意してください。
1 // 署名者が呼び出したかのようにgreetingを適用する2 greeting = req.greeting;3 emit SetGreeting(signer, req.greeting);4 }挨拶を更新します。
脆弱性
これは本番レベルのコードでは_ありません_。重大な攻撃に対して脆弱であり、主要な機能が欠けています。ここではそのいくつかを紹介し、解決方法を説明します。
これらの攻撃のいくつかを確認するには、_Attacks_見出しの下にあるボタンをクリックして何が起こるかを確認してください。Invalid signatureボタンについては、サーバーコンソールをチェックしてトランザクションのレスポンスを確認してください。
サーバーでのサービス拒否
最も簡単な攻撃は、サーバーに対するサービス拒否 (DoS) (opens in a new tab)攻撃です。サーバーはインターネット上のどこからでもリクエストを受け取り、それらのリクエストに基づいてトランザクションを送信します。攻撃者が有効か無効かを問わず、大量の署名を発行するのを防ぐものは何もありません。それぞれがトランザクションを引き起こします。最終的に、サーバーはガス代を支払うためのETHを使い果たします。
この問題の1つの解決策は、レートを1ブロックあたり1トランザクションに制限することです。目的が外部所有アカウントに挨拶を表示することである場合、ブロックの途中で挨拶が何であるかはとにかく重要ではありません。
別の解決策は、アドレスを追跡し、有効な顧客からの署名のみを許可することです。
誤った挨拶の署名
Signature for wrong greetingをクリックすると、特定のアドレス (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) と挨拶 (Hello) に対する有効な署名を送信します。しかし、それは異なる挨拶で送信されます。これによりecrecoverが混乱し、挨拶は変更されますが、アドレスが間違ったものになります。
この問題を解決するには、署名された構造体 (opens in a new tab)にアドレスを追加します。このようにすれば、ecrecoverのランダムなアドレスは署名内のアドレスと一致せず、スマート・コントラクトはメッセージを拒否します。
リプレイ攻撃
Replay attackをクリックすると、「私は0xaA92c5d426430D4769c9E878C1333BDe3d689b3eで、挨拶をHelloにしたい」という同じ署名を、正しい挨拶とともに送信します。その結果、スマート・コントラクトは、そのアドレス (あなたのものではない) が挨拶をHelloに戻したと信じ込みます。これを行うための情報は、トランザクション情報 (opens in a new tab)で公開されています。
これが問題になる場合、1つの解決策はナンス (opens in a new tab)を追加することです。アドレスと数値の間のマッピング (opens in a new tab)を用意し、署名にナンスフィールドを追加します。ナンスフィールドがアドレスのマッピングと一致する場合は、署名を受け入れ、次回のマッピングをインクリメントします。一致しない場合は、トランザクションを拒否します。
別の解決策は、署名されたデータにタイムスタンプを追加し、そのタイムスタンプから数秒間だけ署名を有効として受け入れることです。これはよりシンプルで安価ですが、時間枠内でのリプレイ攻撃のリスクや、時間枠を超えた場合の正当なトランザクションの失敗のリスクがあります。
その他の不足している機能
本番環境で追加する機能は他にもあります。
他のサーバーからのアクセス
現在、任意のアドレスがsponsorSetGreetingを送信できるようにしています。分散化の観点からは、これがまさに私たちが望むことかもしれません。あるいは、スポンサー付きのトランザクションが_私たちの_サーバーを経由することを確実にしたいかもしれません。その場合は、スマート・コントラクトでmsg.senderをチェックします。
いずれにせよ、これは意識的な設計上の決定であるべきであり、単に問題について考えていなかった結果であってはなりません。
エラー処理
ユーザーが挨拶を送信します。次のブロックで更新されるかもしれませんし、されないかもしれません。エラーは目に見えません。本番システムでは、ユーザーは以下のケースを区別できる必要があります。
- 新しい挨拶はまだ送信されていません
- 新しい挨拶は送信され、処理中です
- 新しい挨拶は拒否されました
まとめ
この時点で、ある程度の集中化を犠牲にして、dappユーザーにガスレスな体験を提供できるようになっているはずです。
ただし、これはERC-712をサポートするスマート・コントラクトでのみ機能します。たとえば、ERC-20トークンを送金するには、単なるメッセージではなく、所有者によってトランザクションが署名されている必要があります。解決策はアカウント抽象化 (ERC-4337) (opens in a new tab)です。これについては、将来のチュートリアルで書きたいと思います。
私の他の作品についてはこちらをご覧ください (opens in a new tab)。
ページの最終更新: 2026年3月3日