お疲れ様です。
次世代ビジネス推進部の荻原です。
今回は【ERC4337(1)】EntryPointのhandleOps()の処理を理解する、Paymasterを指定してガス代について検証するの続きです。
ERC4337を利用してERC721のmintとtransferを行うために、必要なコントラクトの準備をしていきます。Alchemy等のSaaSもあるのですが、すべてのコントラクトを自分で用意し、ローカルで完結させようと思います。
この記事内で紹介するコードはあくまで検証用なのでセキュリティ面に問題がある点はご了承ください。
実行環境は次の通りです。
account-abstraction/contracts@0.6.0 openzeppelin/contracts@4.9.5 truffle/hdwallet-provider@1.5.1 truffle@5.4.14 ganache v7.9.1 (@ganache/cli: 0.10.1, @ganache/core: 0.10.1) |
手順としては、
- EntryPoint
- Paymaster
- ERC721
- Smart Contract Account
- ContractFactory
5つのコントラクトを用意し、UserOperation実行に必要な資金をEntryPointにdepositします。
目次
1. EntryPointの準備
2. Paymasterの準備
3. ERC721コントラクトの準備
4. Smart Contract Accountの準備
5. ContractFactoryの準備
6. depositoTo()の実行
1. EntryPointの準備
EntryPointは、Smart Contract Accountを介してUserOperationのcallDataで指定されたコントラクトの関数を実行するコントラクトです。そのトランザクションを発行するのはBundlerというコンポーネントです。
BundlerとしてAlchemyなど外部のサービスを利用する場合はそのサービスが用意したEntryPointのコントラクトを利用することになります。
今回は自分で用意しますが、account-abstractionで用意されているEntryPointをそのままdeployで問題ありません。
import文だけ以下のようにパスを修正しました。
import "../interfaces/IAccount.sol"; import "../interfaces/IPaymaster.sol"; import "../interfaces/IEntryPoint.sol"; import "../utils/Exec.sol"; import "./StakeManager.sol"; import "./SenderCreator.sol"; import "./Helpers.sol"; import "./NonceManager.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; |
↓
import "@account-abstraction/contracts/interfaces/IAccount.sol"; import "@account-abstraction/contracts/interfaces/IPaymaster.sol"; import "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; import "@account-abstraction/contracts/utils/Exec.sol"; import "@account-abstraction/contracts/core/StakeManager.sol"; import "@account-abstraction/contracts/core/SenderCreator.sol"; import "@account-abstraction/contracts/core/Helpers.sol"; import "@account-abstraction/contracts/core/NonceManager.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; |
2. Paymasterの準備
account-abstractionのIPaymasterを継承して作成します。
// Paymaster.sol pragma solidity 0.8.19; import "@account-abstraction/contracts/interfaces/IPaymaster.sol"; contract Paymaster is IPaymaster { function validatePaymasterUserOp(UserOperation calldata, bytes32, uint256) external pure override returns (bytes memory context, uint256 validationData) { context = new bytes(0); validationData = 0; } function postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost) external override {} } |
上記は最低限の実装をしたどのようなUserOperationでも通すPaymasterです。どのようなUserOperationのスポンサーにもなってしまいます。
本来は特定のUserOperationのみスポンサーになりたいはずなので
validatePaymasterUserOp()にUserOperationのpaymasterAndDataを検証する実装をすることができます。paymasterAndDataの20バイト目以降にsignatureやタイムスタンプの情報を含めてそこで利用できます。
Paymasterもdeployをします。
EntryPointとPaymasterに関してはUserOperationのsignatureやpaymasterAndDataの検証方法等に変更がない限りは基本的に1回deployしたものをずっと使うことになるかと思います。
3. ERC721コントラクトの準備
NFTのコントラクトです。ERC4337を使うための特別な実装はありません。通常のERC721を実装しました。
// NFT.sol pragma solidity 0.8.19; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/utils/Counters.sol"; contract NFT is ERC721 { using Counters for Counters.Counter; Counters.Counter internal tokenIds; constructor( string memory name_, string memory symbol_, ) ERC721(name_, symbol_) {} function mint(address to) public virtual { tokenIds.increment(); uint256 tokenId = tokenIds.current(); _safeMint(to, tokenId); } function burn(uint256 tokenId) public { _burn(tokenId); } } |
こちらも先にdeployしておきます。
4. Smart Contract Accountの準備
account-abstractionのSimpleAccountを参考に、今回必要な実装をしました。
IAccountと、NFTを受け取れるようにするためにIERC721Receiverを継承します。
// Account.col pragma solidity ^0.8.19; import "@account-abstraction/contracts/interfaces/IAccount.sol"; import "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; contract Account is IAccount, IERC721Receiver { using ECDSA for bytes32; uint256 constant internal SIG_VALIDATION_FAILED = 1; IEntryPoint private _entryPoint; address private _admin; constructor( IEntryPoint anEntryPoint_, address admin_ ) { _entryPoint = anEntryPoint_; _admin = admin_; } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { return this.onERC721Received.selector; } function admin() public view virtual returns (address) { return _admin; } function entryPoint() public view virtual returns (IEntryPoint) { return _entryPoint; } function _requireFromEntryPoint() internal view { require(msg.sender == address(entryPoint()), "account: not Owner or EntryPoint"); } function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256) external override virtual returns (uint256 validationData) { bytes32 hash = userOpHash.toEthSignedMessageHash(); if (admin() != hash.recover(userOp.signature)) { return SIG_VALIDATION_FAILED; } return 0; } function execute( address dest, uint256 value, bytes calldata func ) external { _requireFromEntryPoint(); _call(dest, value, func); } function executeBatch( address[] calldata dest, bytes[] calldata func ) external { _requireFromEntryPoint(); require(dest.length == func.length, "wrong array lengths"); for (uint256 i = 0; i < dest.length; i++) { _call(dest[i], 0, func[i]); } } function _call(address target, uint256 value, bytes memory data) internal { (bool success, bytes memory result) = target.call{value: value}(data); if (!success) { assembly { revert(add(result, 32), mload(result)) } } } } |
constructorでは、EntryPointのaddressとadminというaddressを引数に取っています。adminは、signerとして許容するアカウントを想定しています。今回は validateUserOp()で、adminが署名したsignature以外の場合は弾くように実装しました。
onERC721Received()はコントラクトでERC721を受け取れるようにするために必要な実装です。
_requireFromEntryPoint()は関数の実行者をEntryPointに限定するためのものです。
execute()または executeBatch()を使って外部のコントラクトを実行します。今回はこれを使ってNFTのmintとtransferを実行します。
こちらのコントラクトはまだdeployしません。UserOperationで初回実行された際にContractFactoryがdeployしてくれます。
5. ContractFactoryの準備
先ほど作成したコントラクトをdeployするためのコントラクトです。
// AccountFactory.sol pragma solidity 0.8.19; import "@openzeppelin/contracts/utils/Create2.sol"; import "./Account.sol"; contract AccountFactory { address[] public accountAddresses; event Deployed(address addr, uint256 salt); function deploy ( IEntryPoint anEntryPoint_, uint256 salt_, address admin_ ) public returns (address) { address addr = getNewAddress( anEntryPoint_, salt_, admin_ ); uint codeSize = addr.code.length; if (codeSize > 0) { return addr; } bytes memory code = abi.encodePacked( type(Account).creationCode, abi.encode( anEntryPoint_, admin_ ) ); require(code.length != 0, "Create2: bytecode length is zero"); assembly { addr := create2(0, add(code, 0x20), mload(code), salt_) } require(addr != address(0), "Create2: Failed on deploy"); accountAddresses.push(addr); emit Deployed(addr, salt_); return addr; } function getAddress (uint _index) public view returns (address) { return accountAddresses[_index]; } function getLength () public view returns (uint) { return accountAddresses.length; } function getNewAddress ( IEntryPoint anEntryPoint_, uint256 salt_, address admin_ ) public view returns (address) { return Create2.computeAddress( bytes32(salt_), keccak256(abi.encodePacked( type(Account).creationCode, abi.encode( anEntryPoint_, admin_ ) )) ); } } |
OpenZeppelinのCreate2を参考にコントラクトをdeployする関数を作成します。
Create2の
deploy()をそのまま叩こうとすると、
SELFBALANCEという禁止されているオペコードが使われておりエラーになるので新しく作成します。
deploy()ではまず
getNewAddress()でdeployするコントラクトのバイトコードとconstructorの引数 とsaltから、新しくdeployするcontract addressを求めます。saltは一意のaddressを求めるために指定します。
求めたaddressにコントラクトがdeployされていればそのaddressを返し、そうでなければdeployします。
そして
accountAddresses[]にdeployしたaddressを格納していきます。
getAddress()はdeploy済みのSmart Contract Accountのaddressを取得します。 getLength ()は accountAddresses[]の長さを返します。
また、 getNewAddress()は直接叩くことでdeploy前にUserOperationのsenderとなる値を求めることができます。
ContractFactoryもdeployしておきます。
Smart Contract Accountの実装を更新した際には新しいContractFactoryをdeployします。
6. depositoTo()の実行
ガス代を支払うための資金をEntryPointに送金しておきます。
今回はPaymasterを利用するのでTruffleを利用して次のように実行しました。
% npx truffle console --network development truffle(development)> var entryPoint = await EntryPoint.at("<EntryPointのaddress>"); truffle(development)> entryPoint.depositTo("<Paymasterのaddress>", {"value": 3000000000000000000}); |
結果は次のように確認できます。
truffle(development)> entryPoint.getDepositInfo("<Paymasterのaddress>"); [ '3000000000000000000', false, '0', '0', '0', deposit: '3000000000000000000', staked: false, stake: '0', unstakeDelaySec: '0', withdrawTime: '0' ] |
valueの値はUserOperationで設定するガス代関連の値から以下のようにして最低限必要な分がわかります。callGasLimit、verificationGasLimit、preVerificationGas、maxFeePerGasを値が決まったらこの値以上となる数値を指定しました。
(callGasLimit + verificationGasLimit * 3 + preVerificationGas) * maxFeePerGas |
次回、これを踏まえて実際にUserOperationを実行します。
参考
- Build and Execute User Operations in Solidity | Account Abstraction From Scratch Course | Part 1
- Build and Deploy a Paymaster in Solidity | Account Abstraction From Scratch Course | Part 2
- Smart Accounts From Scratch
- [ERC4337] プロトコルの変更をせずにAAを実装する仕組みを理解しよう!
- create2によるスマートコントラクトのデプロイ (foundry)
- account-abstraction
- openzeppelin-contracts
- selfBalance() and address(this).balance gas cost