はじめに(Introduction)
前回、ERC-2771: Secure Protocol for Native Meta Transactionsを翻訳したのですが、実装に関しては詳しく書かれていなかったので OpenZeppelinを利用して実装してみようと思います。
バージョン(Version)
主なバージョンです。
- Node.js 20.9.0
- hardhat@2.19.0
- @openzeppelin/contracts@5.0.0
コントラクト(Contract)
ERC-2771でコントラクトは受信者(Recipient)と転送者(Forwarder)の2つ必要となります。
Solidity
受信者(Recipient)は転送者(Forwarder)からの要求を受け付ける為、ERC2771Context.solを継承しています。
重要なポイントは転送者(Forwarder)のコントラクトアドレス(address trustedForwarder
)を設定しているところです。
したがって、転送者(Forwarder)コントラクト先にデプロイしておく必要があります。
メインの機能についてはなんでもいいので、今回はERC20を選択しています。
_msgSender()
と_msgData
については、ERC2771Context
とContext
に実装されているので、ERC2771Context
を優先するようにoverride
します。
// SPDX-License-Identifier: MiT
pragma solidity <0.9.0;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/metatx/ERC2771Context.sol";
contract SampleRecipient is ERC20, Ownable, ERC2771Context {
constructor(
string memory name,
string memory symbol,
address trustedForwarder
)
ERC20(name, symbol)
Ownable(msg.sender)
ERC2771Context(trustedForwarder)
{}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
function _msgSender()
internal
view
override(Context, ERC2771Context)
returns (address)
{
return super._msgSender();
}
function _msgData()
internal
view
override(Context, ERC2771Context)
returns (bytes calldata)
{
return super._msgData();
}
}
転送者(Forwarder)はERC2771Forwarderを継承しているだけの単純なものとなっています。
// SPDX-License-Identifier: MiT
pragma solidity <0.9.0;
import "@openzeppelin/contracts/metatx/ERC2771Forwarder.sol";
contract SampleForwarder is ERC2771Forwarder {
constructor(string memory name) ERC2771Forwarder(name) {}
}
コンパイル(Compile)
Solidityをコンパイルします。
以下のコマンドで実行します。
npx hardhat compile
成功するとartifacts
フォルダが生成されます。
artifacts/contracts/SampleForwarder.sol
フォルダとartifacts/contracts/SampleRecipient.sol
フォルダ内にあるSampleForwarder.json
ファイルとSampleRecipient.json
ファイルを使います。
artifacts
├─@openzeppelin
├─build-info
└─contracts
├─SampleForwarder.sol
│ SampleForwarder.dbg.json
│ SampleForwarder.json
│
└─SampleRecipient.sol
SampleRecipient.dbg.json
SampleRecipient.json
実装(Implementation)
ソースコードは、Githubに置いてあります。
重要な箇所だけ解説します。
インポート(Import)
ES Modulesで実装します。
インポートは以下のとおりとなります。
ethers
は6系を使用します。
また、コンパイルで作成されたSampleForwarder.json
ファイルとSampleRecipient.json
ファイルをjsonとしてインポートします。
"use strict";
import { ethers } from "https://cdnjs.cloudflare.com/ajax/libs/ethers/6.7.0/ethers.min.js";
import SampleRecipientJson from "./SampleRecipient.json" assert {type: "json"};
import SampleForwarderJson from "./SampleForwarder.json" assert {type: "json"};
プロバイダー取得(Provider)
JsonRpcProvider
でプロバイダーを作成します。
接続確認の為、ブロック数を取得しています。
var provider = null;
async function connectProvider(providerUrl) {
if (provider) {
try {
provider.destroy();
} catch (e) {
console.log("provider.destroy err", e);
}
}
provider = null;
try {
provider = new ethers.JsonRpcProvider(providerUrl);
let blockNumber = await provider.getBlockNumber();
console.log("getBlockNumber", blockNumber);
} catch (err) {
console.log("connectProvider err", err);
if (provider) {
try {
provider.destroy();
} catch (e) { }
}
provider = null;
throw err;
}
}
署名者(Signers)
署名者を作成します。
サンプル実装なのでmnemonic
をlocalStorage
に保存していますが、おすすめできません。
var signers = [];
/**
* ※:サンプル実装なので真似しないでください。
*/
function setSiners(provider, password) {
signers.length = 0;
let mnemonic = localStorage.getItem("mnemonic");
if (!mnemonic) {
mnemonic = ethers.Wallet.createRandom().mnemonic.phrase;
// mnemonicをローカルストレージに入れるのはセキュリティ的にNGです。
localStorage.setItem("mnemonic", mnemonic);
}
let parent = ethers.HDNodeWallet.fromPhrase(mnemonic, password, "m/44'/60'/0'/0");
for (let i = 0; i < 4; i++) {
let wallet = parent.deriveChild(i);
let signer = wallet.connect(provider);
signers.push(signer);
}
}
デプロイ(Deploy)
コントラクトをデプロイします。
Recipient
コントラクトにはForwarder
コントラクトのアドレスが必要なので、Forwarder
コントラクトからデプロイします。
async function deploy(signer) {
let sampleForwarderFactory = new ethers.ContractFactory(SampleForwarderJson.abi, SampleForwarderJson.bytecode, signer);
let sampleForwarderContract = await sampleForwarderFactory.deploy("SampleForwarder");
await sampleForwarderContract.waitForDeployment();
let sampleRecipientFactory = new ethers.ContractFactory(SampleRecipientJson.abi, SampleRecipientJson.bytecode, signer);
let sampleRecipientContract = await sampleRecipientFactory.deploy("SampleRecipient", "SRC", sampleForwarderContract.target);
await sampleRecipientContract.waitForDeployment();
return [sampleForwarderContract.target, sampleRecipientContract.target];
}
リクエスト生成(Request of Meta Transaction)
ERC20のtransfer
を呼び出すリクエストを作成します。
EIP-712の署名が必要なので、ドメインを取得します。
ドメインはchainId
、name
、verifyingContract
、version
が必要になります。
ForwardRequestは以下のとおりです。
-
from
: 代理のアドレス、 リクエストの署名者と同じである必要があります。 -
to
: Forwarderが呼び出すアドレスです。(ここでは、Recipientコントラクトのアドレス) -
value
: リクエストに添付するネイティブトークンの量です。(ここでは、0です。) -
gas
: リクエストで転送されるガス制限の量です。(ここでは、5000としています。) -
nonce
: 再実行可能性を回避するため、無効化を要求するための一意のトランザクション順序識別子です。(Forwarderコントラクトから取得します。) -
deadline
: このタイムスタンプを過ぎるとリクエストは実行できなくなります。(ここでは、現在時刻から1時間までとしてます。) -
data
: リクエストで送信するエンコードされたmsg.data
です。(ここでは、ERC20のtransfer
のデータです。)
async function signMetaTransaction(forwarderAddress, recipientAddress, signer, toAddress, amount) {
let sampleForwarder = new ethers.Contract(forwarderAddress, SampleForwarderJson.abi, signer);
let sampleRecipient = new ethers.Contract(recipientAddress, SampleRecipientJson.abi, signer);
let eip712domain = await sampleForwarder.eip712Domain();
let domain = {
chainId: eip712domain.chainId,
name: eip712domain.name,
verifyingContract: eip712domain.verifyingContract,
version: eip712domain.version,
};
let types = {
ForwardRequest: [
{ type: "address", name: "from" },
{ type: "address", name: "to" },
{ type: "uint256", name: "value" },
{ type: "uint256", name: "gas" },
{ type: "uint256", name: "nonce" },
{ type: "uint48", name: "deadline" },
{ type: "bytes", name: "data" },
],
};
// ERC20 transfer
let iface = new ethers.Interface(SampleRecipientJson.abi);
let data = iface.encodeFunctionData("transfer", [toAddress, amount]);
let value = {
from: signer.address,
to: sampleRecipient.target,
value: 0n,
gas: 50000n,
nonce: await sampleForwarder.nonces(signer.address),
deadline: (Math.floor(Date.now() / 1000) + 3600),
data: data,
};
let sign = await signer.signTypedData(domain, types, value);
let request = {
from: value.from,
to: value.to,
value: value.value,
gas: value.gas,
deadline: value.deadline,
data: value.data,
signature: sign,
};
return request;
}
送信(Send Meta Transaction)
Gas代を肩代わりする転送者(Relayer)がリクエストを送信します。
送信する前に検証(verify
)しています。
async function sendMetaTransaction(forwarderAddress, signer, request) {
let sampleForwarder = new ethers.Contract(forwarderAddress, SampleForwarderJson.abi, signer);
let result = await sampleForwarder.verify(request);
if (result) {
let tx = await sampleForwarder.execute(request);
await tx.wait(1);
} else {
throw new Error("Failed to verify");
}
}
デモ(Demo)
実際に動かしてみます。
準備
Hardhatのノードを起動します。
npx hardhat node
Meta Transaction Demoページを開きます。
デモ実施
デモの実施手順は以下となります。
- 「Connect」ボタンを押下して、ノードに接続します。
- RelayerにCoinが無い場合は「Faucet」ボタンを押下してRelayerにCoinを付与します。
- 「Deploy」ボタンを押下してコントラクトをデプロイします。
- 「Mint Token」ボタンを押下して各Signerにトークンを付与します。
- 「Sign Meta Transaction」ボタンを押下してリクエストを生成します。
- 「Send Meta Transaction」ボタンを押下してRelayerが送信します。
結果(Result)
RelayerがGas代を肩代わりしているので、RelayerのCoinが減っていますが、ERC20のTokenはUser1からUser2へ移転していることがわかると思います。
まとめ(Conclusion)
OpenZeppelinを利用すると簡単にデモが作成できました。
基本的に一般ユーザーは暗号資産を持っていないので、Gas代を肩代わりしてくれるのは便利だと思います。
ただ、コントラクト側に「ERC2771Context」を含めておく必要があるので設計をあるていど考えておく必要がありそうです。
※※※追記(2023/11/19)※※※
デモ画面ではURLにAlchemyなどから発行されるHTTPSのAPIのURLなどを入れればテストネットでもデモ可能でした。
PolygonのテストネットMumbaiとEthereumのテストネットSepoliaで確認済みです。