お疲れさまです。
次世代ビジネス推進部 Web3開発Gの荻原です。
前回【ERC4337 その1】ERC4337についてでは、EOAの秘密鍵のリスクからの脱却を目指したERC4337について紹介しました。
ERC4337を試してみることはStackupのチュートリアルを進めると簡単にできるのですが、今回はこれを応用して自分で発行したNFTをERC4337を使って転送してみようと思います。
目次
1. 実装する
今回はとりあえず検証のためにNFTが転送できることをゴールとしたため、実装が不十分であったり実用には使えない部分もあったりします。ご了承ください。
StackupのFree Planに登録していることを前提として進めます。PaymasterについてはStackupのFree Planの範囲外のため使用しません。
また、ネットワークはMumbaiネットワークを使用します。
環境は以下の通りです。
@account-abstraction/contracts@0.6.0 @openzeppelin/contracts@4.9.1 Truffle v5.4.26 (core: 5.4.26) Solidity - 0.8.19 (solc-js) Node v16.20.0 Web3.js v1.5.3 ethers@5.7.2 userop@0.2.0 |
erc-4337-examplesは以下の時点のものを利用しています。
erc-4337-examples GitHub
また、Truffleの詳しい使い方はこの記事では割愛しています。
スマートコントラクト
今回はContractFactoryは実装せず、デプロイはTruffleで行います。
コントラクト(NFTTest.solとしました)の実装はETH Infinitismから提供されているaccount-abstractionライブラリのSimpleAccount.solを参考にして実装していきます。
BaseAccount.solの継承
まず、account-abstractionのBaseAccountを継承します。
// NFTTest.sol import "@account-abstraction/contracts/core/BaseAccount.sol"; contract NFTTest is ERC721, Ownable, BaseAccount { } |
@account-abstraction/contractsを利用するためにnpmでインストールもしておきます。
$ npm i @account-abstraction/contracts |
最低限必要な実装についてはこの後解説しますが、BaseAccountの実装を見るとNFTTestにどのような実装が必要か何となくわかるかと思います。
EntryPointのGetter
SimpleAccountのコンストラクタを見るとEntryPointのコントラクトアドレスを保存しています。
// SimpleAccount.sol constructor(IEntryPoint anEntryPoint) { _entryPoint = anEntryPoint; ~ 略 ~ |
この _entryPointは以下で使われています。
// SimpleAccount.sol function entryPoint() public view virtual override returns (IEntryPoint) { return _entryPoint; } |
BaseAccountも見てみるとコメントで説明があり、SimpleAccountはその通りに実装されていることがわかります。
// BaseAccount.sol /** * return the entryPoint used by this account. * subclass should return the current entryPoint used by this account. */ function entryPoint() public view virtual returns (IEntryPoint); |
そして entryPoint()は色々な場所で使われることになるので実装しておきます。
// NFTTest.sol constructor( string memory name_, string memory symbol_, IEntryPoint anEntryPoint ) ERC721(name_, symbol_) { _entryPoint = anEntryPoint; } function entryPoint() public view virtual override returns (IEntryPoint) { return _entryPoint; } |
関数のアクセスコントロール
BaseAccountを見てみると関数へのアクセスコントロールとしては以下が実装されています。
// BaseAccount.sol function _requireFromEntryPoint() internal virtual view { require(msg.sender == address(entryPoint()), "account: not from EntryPoint"); } |
ERC4337を使う場合はEntryPointのコントラクトが
msg.senderとなって実行されるため、onlyOwner等を使っているところでEntryPointを許可する必要があります。
ただし、今回は部分的にTruffleからメソッドを実行するため、SimpleAccountに倣ってownerとEntryPoint両方を許可します。
// SimpleAccount.sol function _requireFromEntryPointOrOwner() internal view { require(msg.sender == address(entryPoint()) || msg.sender == owner, "account: not Owner or EntryPoint"); } |
SimpleAccountに倣ってfunctionで実装し、例えば以下のように各関数のいちばん最初で実行するようにします。
// NFTTest.sol function mint(address to) public virtual { _requireFromEntryPointOrOwner(); ~ 略 ~ |
mintの実装
NFTとしての実装は最低限mintを外から叩けるように実装しておきます。
以下はmintに関わる部分だけ抽出しています。
import "@openzeppelin/contracts/utils/Counters.sol"; contract NFTTest is ERC721, Ownable, BaseAccount { Counters.Counter internal tokenIds; function mint(address to) public virtual { _requireFromEntryPointOrOwner(); tokenIds.increment(); uint256 tokenId = tokenIds.current(); _mint(to, tokenId); _approve(address(entryPoint()), tokenId); } } |
最後の _approve()は、今回はmintした後EntryPointに safeTransferFrom()を実行してもらうため、このtokenの転送を許可しています。
署名の検証
SimpleAccountを見ると _validateSignature()という関数があります。
// SimpleAccount.sol function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) internal override virtual returns (uint256 validationData) { bytes32 hash = userOpHash.toEthSignedMessageHash(); if (owner != hash.recover(userOp.signature)) return SIG_VALIDATION_FAILED; return 0; } |
これはBaseAccountの
validateUserOp()で呼び出されており、さらに
validateUserOp()はEntryPointからトランザクション実行時に呼び出されます。
そのため、これも実装しておきます。
上記を見るとわかるかと思いますが、署名の方法はここの実装次第で自由に決めることができます。
今回はSimpleAccountをそのままコピペしてしまいます。
また、 validateUserOp()内では _validateNonce()も呼び出されています。ただし、BaseAccountの _validateNonce()定義部分の以下のコメントの通り、nonceがユニークであるかどうかという検証はEntryPoint側(参照)に実装されており、独自にnonceについて制限したければここで実装することになります。今回は特に実装しません。
The actual nonce uniqueness is managed by the EntryPoint, and thus no other action is needed by the account itself.
ここまでで、今回ERC4337を利用するために最低限必要なスマートコントラクトの実装は完了しました。
MATICを受け取るための実装
今回はPaymasterは利用せずContractFactoryも実装していないため、コントラクト自身がガス代を支払う必要があります。そのためコントラクトをデプロイ後、ガス代用のMATICを受け取るための実装もしておきます。
// NFTTest.sol receive() external payable { } fallback() external payable { } function getBalance() public view returns (uint256) { return address(this).balance; } |
スマートコントラクトの実装全体
ここまででスマートコントラクトの実装は完了です。
コード全体は以下の通りです。
// SPDX-License-Identifier: MIT pragma solidity 0.8.19; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/Counters.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@account-abstraction/contracts/core/BaseAccount.sol"; contract NFTTest is ERC721, Ownable, BaseAccount { using Counters for Counters.Counter; using ECDSA for bytes32; IEntryPoint private immutable _entryPoint; Counters.Counter internal tokenIds; constructor( string memory name_, string memory symbol_, IEntryPoint anEntryPoint ) ERC721(name_, symbol_) { _entryPoint = anEntryPoint; } /// @inheritdoc BaseAccount function entryPoint() public view virtual override returns (IEntryPoint) { return _entryPoint; } /// implement template method of BaseAccount function _validateSignature( UserOperation calldata userOp, bytes32 userOpHash ) internal virtual override returns (uint256 validationData) { bytes32 hash = userOpHash.toEthSignedMessageHash(); if (owner() != hash.recover(userOp.signature)) return SIG_VALIDATION_FAILED; return 0; } receive() external payable {} fallback() external payable {} function getBalance() public view returns (uint256) { return address(this).balance; } function _requireFromEntryPointOrOwner() internal view { require( msg.sender == owner() || msg.sender == address(entryPoint()), "account: not Owner or EntryPoint" ); } function mint(address to) public virtual { _requireFromEntryPointOrOwner(); tokenIds.increment(); uint256 tokenId = tokenIds.current(); _mint(to, tokenId); _approve(address(entryPoint()), tokenId); } } |
compileしてABIを作成しておきます。
$ truffle compile |
UserOperationをBundlerに送信する
Stackupから提供されているerc-4337-examplesを元にし、NFTTestの safeTranferFrom()を実行できるように実装してみます。
最初にこのリポジトリをcloneしておきます。
$ git clone https://github.com/stackup-wallet/erc-4337-examples.git |
ABIの準備
先程作成したNFTTestのABIをerc-4337-examples/src/NFTTestabi.tsに配置しておきます。
以下の形式です。
// erc-4337-examples/src/NFTTestabi.ts export const NFTTest = [ { "inputs": [ { "internalType": "string", "name": "name_", "type": "string" }, { "internalType": "string", "name": "symbol_", "type": "string" }, { "internalType": "contract IEntryPoint", "name": "anEntryPoint", "type": "address" } ], "stateMutability": "nonpayable", "type": "constructor" }, ~ 略 ~ ] |
erc-4337-examples/src/index.tsに以下を追記します。
// erc-4337-examples/src/index.ts export * from "./NFTTestabi"; |
UserOperationの作成
erc-4337-examples/scripts/simpleAccount/transfer.tsを参考に実装していきます。
erc-4337-examples/scripts/simpleAccount/にsafeTransferFrom.tsを作成します。
callDataはethers.jsの
encodeFunctionData()で取得可能です。
providerはBundlerのJSON-RPC URI(config.jsonの
rpcUrl)を指定して取得します。
実行したいContractAddress(
sender)、実行したいメソッドの引数(
from、
to、
tokenId)はコマンドラインから取得する想定です。
import { NFTTest } from "../../src"; const provider = new ethers.providers.JsonRpcProvider(config.rpcUrl); const myContract = new ethers.Contract(sender, NFTTest, provider); myContract.interface.encodeFunctionData("safeTransferFrom(address, address, uint256)", [from, to, tokenId]) |
nonceについては、EntryPointの getNonce()を叩くメソッドがBaseAccountに実装されているため、これを用います。
const nonceBigNum = await myContract.functions.getNonce(); nonceBigNum[0] |
今回NFTTestに実装した _validateSignature()に対応するsignatureは以下のように取得します。 userOpHashはUserOperationやEntryPointのアドレス、Chain IDを合わせてハッシュ化した値です。(具体的にはHow to sign and validate the signature of UserOperation in EIP-4337 (Account Abstraction)参照)
const signer = new ethers.Wallet(config.signingKey); signer.signMessage(ethers.utils.arrayify(userOpHash)); |
そして以下のようにしてuserop.jsの UserOperationBuilder()を作成します。
import { Presets, UserOperationBuilder } from "userop"; const builder = new UserOperationBuilder().useDefaults( { nonce: nonceBigNum.nonce, sender: sender, signature: await signer.signMessage(ethers.utils.arrayify(ethers.utils.keccak256("0xdead"))), callData: myContract.interface.encodeFunctionData("safeTransferFrom(address, address, uint256)", [from, to, tokenId]) } ) .useMiddleware(Presets.Middleware.getGasPrice(provider)) .useMiddleware(Presets.Middleware.estimateUserOperationGas(provider)) .useMiddleware(Presets.Middleware.EOASignature(signer)); |
Presets.Middleware.getGasPrice()はmaxFeePerGasとmaxPriorityFeePerGasの最新の値を取得します。
Presets.Middleware.estimateUserOperationGas()はBundlerのJSON-RPC APIである eth_estimateUserOperationGasを実行し、preVerificationGas、verificationGasLimit、callGasLimitの適切な値を計算します。
Presets.Middleware.EOASignature()は先程紹介したsignatureを求める部分をやってくれます。 useDefaults()では estimateUserOperationGas()実行時に必要になる仮の署名をsignatureに指定していますが、 Presets.Middleware.EOASignature()実行時に置き換えられます。
ここまででUserOperationの作成は完了です。次にこれをBundlerに送信します。
こちらもuserop.jsのClientを使用します。
import { Client } from "userop"; const client = await Client.init(config.rpcUrl, config.entryPoint); const res = await client.sendUserOperation( builder, { dryRun: opts.dryRun, onBuild: (op) => console.log("Signed UserOperation:", op), } ); |
従来のtransactionであればJSON-RPC APIの
eth_sendTransactionを呼び出します。ERC4337ではBundlerのJSON-RPC APIの
eth_sendUserOperationを呼び出します。
第1引数には先程作成したUserOperationを設定したbuilderを指定します。
第2引数はoptionです。dryRunを指定すると
eth_sendUserOperationを呼び出さず、buildのみをします。onBuildはbuild時に実行する内容を指定しています。
コード全体は以下の通りです。
// erc-4337-examples/scripts/simpleAccount/safeTransferFrom.ts import { ethers } from "ethers"; import { NFTTest } from "../../src"; // @ts-ignore import config from "../../config.json"; import { Client, Presets, UserOperationBuilder } from "userop"; import { CLIOpts } from "../../src"; export default async function main( c: string, f: string, t: string, i: number, opts: CLIOpts ) { const provider = new ethers.providers.JsonRpcProvider(config.rpcUrl); const contractAddress = ethers.utils.getAddress(c); const from = ethers.utils.getAddress(f); const to = ethers.utils.getAddress(t); const tokenId = i; const sender = contractAddress; const myContract = new ethers.Contract(sender, NFTTest, provider); const nonceBigNum = await myContract.functions.getNonce(); const signer = new ethers.Wallet(config.signingKey); const builder = new UserOperationBuilder().useDefaults( { nonce: nonceBigNum[0], sender: sender, signature: await signer.signMessage(ethers.utils.arrayify(ethers.utils.keccak256("0xdead"))), callData: myContract.interface.encodeFunctionData("safeTransferFrom(address, address, uint256)", [from, to, tokenId]) } ) .useMiddleware(Presets.Middleware.getGasPrice(provider)) .useMiddleware(Presets.Middleware.estimateUserOperationGas(provider)) .useMiddleware(Presets.Middleware.EOASignature(signer)); const client = await Client.init(config.rpcUrl, config.entryPoint); const res = await client.sendUserOperation( builder, { dryRun: opts.dryRun, onBuild: (op) => console.log("Signed UserOperation:", op), } ); console.log(`UserOpHash: {res.userOpHash}`); console.log("Waiting for transaction..."); const ev = await res.wait(); console.log(`Transaction hash: ${ev?.transactionHash ?? null}`); } |
後は必要な引数をコマンドから取得するようにします。
以下のようにerc-4337-examples/scripts/simpleAccount/index.tsに追記します。(Paymasterは使わないですが、型定義の関係で入れています)
// erc-4337-examples/scripts/simpleAccount/index.ts #!/usr/bin/env node import { Command } from "commander"; import address from "./address"; import transfer from "./transfer"; ~ 略 ~ import safeTransferFrom from "./safeTransferFrom"; //←追記する const program = new Command(); ~ 略 ~ // 以下を追記する program .command("safeTransferFrom") .description("safeTransferFrom NFT") .option( "-dr, --dryRun", "Builds the UserOperation without calling eth_sendUserOperation" ) .option("-pm, --withPaymaster", "Use a paymaster for this transaction") .requiredOption("-c, --contract <address>", "contract address") .requiredOption("-f, --from <address>", "from address") .requiredOption("-t, --to <address>", "to address") .requiredOption("-i, --tokenId <int>", "tokenId to transfer") .action(async (opts) => safeTransferFrom(opts.contract, opts.from, opts.to, opts.tokenId, { dryRun: Boolean(opts.dryRun), withPM: Boolean(opts.withPaymaster), }) ); |
2. 実行する
まずはTruffleを使ってMumbaiネットワークにNFTTestをデプロイします。
$ truffle migrate --network mumbai |
0xB4080F494aB7aE2d44C205958cCcd2BAe3E25A95でデプロイできました。
このコントラクトにMATICを送金します。
$ truffle console --network mumbai truffle(mumbai)> web3.eth.sendTransaction({to:"0xB4080F494aB7aE2d44C205958cCcd2BAe3E25A95", from:"0x782a373FbE861C4ca5E4ac2f73F5079502BA6e12" value: web3.utils.toWei('0.1')}) |
mintをします。mintももちろん先程実装したUserOperationのcallDataをmintに変更するだけでERC4337を利用できますが、今回はTruffleを使ってしまいます。
% truffle console --network mumbai truffle(mumbai)> let specificInstance = await NFT.at("0xB4080F494aB7aE2d44C205958cCcd2BAe3E25A95"); truffle(mumbai)> specificInstance.mint("0x390874CA3B2ae1E0DEdd2aA2f00583FEccd7DA7E"); |
この後、ERC4337を使ってNFTを転送していくのですが、そのトランザクションはEntryPointのnonceが0になります。
userop.jsのPresetは初回実行時はinitCodeを用いてコントラクトをデプロイする前提となってしまっているため、今回の場合はその制限のある箇所をコメントアウトする必要があります。
// userop/dist/preset/middleware/gasLimit.js const estimateUserOperationGas = (provider) => (ctx) => __awaiter(void 0, void 0, void 0, function* () { // ここから↓ // if (ethers_1.ethers.BigNumber.from(ctx.op.nonce).isZero()) { // ctx.op.verificationGasLimit = ethers_1.ethers.BigNumber.from(ctx.op.verificationGasLimit).add(yield estimateCreationGas(provider, ctx.op.initCode)); // } // ↑ここまでコメントアウト const est = (yield provider.send("eth_estimateUserOperationGas", [ (0, utils_1.OpToJSON)(ctx.op), ctx.entryPoint, ])); ~ 略 ~ |
最後にerc-4337-examples/config.jsonで環境変数を設定します。
rpcUrlにはStackupのAPI Keyを登録します。これでStackupのBundlerを利用できます。
signingKeyは署名に使うEOAのprivate keyです。今回はスマートコントラクトの
_validateSignature()でownerのみ署名可能としたのでownerのアドレスを指定しました。
entryPointはデフォルトのままで、eth-infinitismのEntryPointを利用できます。
その他は今回は使いません。
{ "rpcUrl": "https://api.stackup.sh/v1/node/<API Key>", "signingKey": "<private key>", "entryPoint": "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789", ~ 略 ~ } |
ここまでで準備は完了です。
コマンドを実行します。
$ yarn run simpleAccount safeTransferFrom -c 0xB4080F494aB7aE2d44C205958cCcd2BAe3E25A95 -f 0x390874CA3B2ae1E0DEdd2aA2f00583FEccd7DA7E -t 0x545E527DC625fde45eB69ff5682D112975a72ef9 -i 1 yarn run v1.22.19 $ ts-node scripts/simpleAccount/index.ts safeTransferFrom -c 0xB4080F494aB7aE2d44C205958cCcd2BAe3E25A95 -f 0x390874CA3B2ae1E0DEdd2aA2f00583FEccd7DA7E -t 0x545E527DC625fde45eB69ff5682D112975a72ef9 -i 1 Signed UserOperation: { sender: '0xB4080F494aB7aE2d44C205958cCcd2BAe3E25A95', nonce: '0x0', initCode: '0x', callData: '0x42842e0e000000000000000000000000390874ca3b2ae1e0dedd2aa2f00583feccd7da7e000000000000000000000000545e527dc625fde45eb69ff5682d112975a72ef90000000000000000000000000000000000000000000000000000000000000001', callGasLimit: '0x10a67', verificationGasLimit: '0x15e75', preVerificationGas: '0xac3c', maxFeePerGas: '0x6507a5e2', maxPriorityFeePerGas: '0x6507a5c0', paymasterAndData: '0x', signature: '0xef1b57d37ae6e1f79414a25a967fa7fbde68c1c049ba17f6c297ac9e8fc546b066780f72a32019140add079b14f3f62ecd0e7274476cc3b3661dda052e4066091b' } UserOpHash: 0x64f9d9a7ad9d3a257f54b6c6b63fcf5c7264cc5961b7bb730340650955b4f695 Waiting for transaction... Transaction hash: 0xc05e16bdce0a10dc6fc14b7c41ac2f16a6a990207c7ecdbd0a10334b021edc7d ✨ Done in 20.04s. |
以上でERC4337を使ってNFTを転送してみることができました。
3. transactionを見てみる
実行されたtransactionを見てみましょう。
ポイントとなるのは次の部分かと思います。
ガス代の支払いの流れです。
まず、コントラクト→EntryPointに支払われています。
次にEntryPoint→Bundlerに余った分が支払われています。
このトランザクションで実行されている内容です。
EntryPointの
handleOps()というメソッドで、引数はUserOperationとBundlerのアドレスです。
イベントのログを見てみると、ガス代の支払い周り以外ではDeposited、BeforeExecution、Transfer、UserOperationEventがemitされていました。
- DepositedはEntryPointでコントラクト→EntryPointに支払われたガス代のログです。
- BeforeExecutionは handleOps()の中でUserOperationの内容(今回はsafeTransferFrom)の実行前にemitされます。
- TransferはsafeTransferFromが実行された際にemitされます。
- UserOperationEventは全体の実行完了後にemitされています。
これらを元にするとEntryPointの処理の流れをさらに詳しく理解することもできると思います。
4. まとめ
今回はERC4337を利用してNFTを転送してみました。
userop.jpやaccount-abstractionを利用することで実装の難易度としては簡単だったのではないかと思います。
ERC4337について勉強する際の手助けになれば幸いです。
最後まで読んでいただき、ありがとうございました。
5. 参考
この記事を書いた人
最近書いた記事
- 2023.12.24気難しいBlockchainへの対処法
- 2023.09.22レコチョクチケット特有の機能について
- 2023.07.18【ERC4337 その2】NFTを転送してみる
- 2023.07.14【ERC4337 その1】ERC4337について