目次

目次

【ERC4337 その2】NFTを転送してみる

アバター画像
荻原菜穂
アバター画像
荻原菜穂
最終更新日2023/07/18 投稿日2023/07/18

お疲れさまです。 次世代ビジネス推進部 Web3開発Gの荻原です。

前回【ERC4337 その1】ERC4337についてでは、EOAの秘密鍵のリスクからの脱却を目指したERC4337について紹介しました。

ERC4337を試してみることはStackupチュートリアルを進めると簡単にできるのですが、今回はこれを応用して自分で発行したNFTをERC4337を使って転送してみようと思います。

目次

  1. 実装する
  2. 実行する
  3. transactionを見てみる
  4. まとめ
  5. 参考

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/contractscode>@account-abstraction/contracts</codeを利用するために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)、実行したいメソッドの引数(fromtotokenId)はコマンドラインから取得する想定です。

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) =&gt; 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) =&gt; (ctx) =&gt; __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を見てみましょう。

ポイントとなるのは次の部分かと思います。

ガス代の支払いの流れです。

スクリーンショット 2023-06-20 19.25.32.png

まず、コントラクト→EntryPointに支払われています。 次にEntryPoint→Bundlerに余った分が支払われています。

このトランザクションで実行されている内容です。

スクリーンショット 2023-06-20 19.40.34.png

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. 参考

アバター画像

荻原菜穂

目次