お疲れ様です。
次世代ビジネス推進部の荻原です。
今回は【ERC4337(2)】EntryPoint、Paymaster、Smart Contract Account、ContractFactoryの実装の仕方の続き、第3回目です。
前回の準備を踏まえて実際に handleOps()をPythonで実行してみます。”Pythonで”というところがポイントで、JavaScriptやTypeScriptのERC4337用のライブラリは使用せず、web3.pyとeth_accountとeth_abiを使うので、ある程度どのような処理が行われているのか中身がわかりやすいかと思います。
handleOps()は複数のUserOperationを実行できるので以下をまとめて1回でやってみます。
- Smart Contract Account1を作成
- Smart Contract Account2を作成、Smart Contract Account2宛にmint
- Smart Contract Account2→Smart Contract Account1にtransfer
UserOperationを実行するには、まずUserOperationを作成してそれを引数にとってeth_sendRawTransactionで handleOps()を実行します。
UserOperationは
{ sender, nonce, initCode, callData, callGasLimit, verificationGasLimit, preVerificationGas, maxFeePerGas, maxPriorityFeePerGas, paymasterAndData, signature } |
で構成されているのでこれらを1つずつ求めていきます。
コード全体はAppendixに載せました。
実行環境は以下です。全体的にバージョンが古いのは今回は検証なのでご了承ください。
web3 5.31.0 Python 3.9.4 eth_account 0.5.9 eth-abi 2.0.0 truffle/hdwallet-provider@1.5.1 truffle@5.4.14 ganache v7.9.1 (@ganache/cli: 0.10.1, @ganache/core: 0.10.1) |
目次
1. Account1を作成するUserOperationの作成
2. Account2を作成、Account2宛にmintするUserOperationの作成
3. Account2→Account1にtransferするためのUserOperationを作成
4. handleOps()の実行
5. 結果
1. Account1を作成するUserOperationの作成
Smart Contract AccountのContractFactoryの deploy()を実行するためのUserOperationを作成していきます。
まず諸々初期化します。
from eth_abi import encode_abi from eth_account.messages import encode_defunct from web3 import Web3 entry_point_address = "0x" paymaster_address = "0x" account_factory_address = "0x" admin_address = "0x" private_key = "" nft_contract_address = "0x" web3 = Web3(Web3.HTTPProvider("http://127.0.0.1:8545", request_kwargs={"timeout": 60})) transaction_options = { "from": admin_address, "nonce": web3.eth.getTransactionCount(admin_address), "gas": 500000000, } entry_point_contract = web3.eth.contract(address=entry_point_address, abi=entry_point_contract_json["abi"]) account_contract = web3.eth.contract( abi=account_contract_json["abi"], bytecode=account_contract_json["bytecode"] ) account_factory_contract = web3.eth.contract( address=account_factory_address, abi=account_factory_contract_json["abi"] ) nft_contract = web3.eth.contract(address=nft_contract_address, abi=nft_contract_json["abi"]) handle_ops = [] |
entry_point_address、 paymaster_address、 account_factory_address、 nft_contract_addressには事前にdeployしていた各コントラクトのaddressを入れておきます。 admin_addressと private_keyはトランザクションに署名するEOAのaddressとそのprivate keyです。
transaction_optionsではトランザクション発行時のoptionを設定しています。Gasはデフォルトだと検証中に足りなくなることがあったので多めに設定しています。
entry_point_contract_json、 account_contract_json、 account_factory_contract_json、 nft_contract_jsonにはそれぞれEntryPoint、Account、ContractFactory、NFTのコントラクトのABIとbytecodeがdict型で格納されています。
handle_opsに3つのUserOperationを格納します。
次にinitCodeを作成します。これはContractFactoryの deploy()を実行するためのものです。initCodeは次の形なのでこれを求めます。
initCode = "<ContractFactoryのaddress><コントラクトをdeployする関数名のhash値4バイト><引数32バイトずつ>" |
salt1 = account_factory_contract.functions.getLength().call() constructor_args1 = { "anEntryPoint_": entry_point_address, "salt_": salt1, "admin_": admin_address, } init_code1 = account_factory_contract.encodeABI( fn_name="deploy", kwargs=constructor_args1, ) _init_code1 = account_factory_address + init_code1.split("0x")[-1] |
今回はContractFactoryは1つなので、 getLength()の値が一意で取れます。そのためそれをsaltとして使うこととします。実行したい関数名と引数をencodeしてContractFactoryのcontract addressとくっつけて完成です。
次にsenderを求めます。先ほど作成した deploy()の引数 constructor_argsをそのままContractFactoryの getNewAddress()に指定します。
def get_new_address(account_factory_contract, constructor_args): return account_factory_contract.functions.getNewAddress( constructor_args["anEntryPoint_"], constructor_args["salt_"], constructor_args["admin_"], ).call() contract_address1 = get_new_address(account_factory_contract, constructor_args1) |
callDataで実行したいものは今回はないので 0xです。
call_data1 = "0x" |
nonceはEntryPointの getNonce()という関数で取得可能です。
nonce1 = entry_point_contract.functions.getNonce(contract_address1, 0).call() |
トランザクション発行時のnonceはトランザクションの署名者が発行したトランザクション数になりますが、こちらのnonceは全く別の値で、次の形でEntryPointが保持しています。
nonceSequenceNumber[sender][key] |
指定したsenderについてそのEntryPointで実行したUserOperation数になります。今回の場合で言うとsenderをinitCodeでdeployする際が0、その後deployされたコントラクトでメソッドを実行する度にインクリメントされます。
paymasterAndDataは今回はPaymasterのaddressです。Paymasterのコントラクトでsignatureやタイムスタンプの検証を行っている場合は20バイト以降に続けます。
callGasLimit 、 callGasLimit、 verificationGasLimit、 preVerificationGas、 maxFeePerGas、 maxPriorityFeePerGasの値は今回は適当に、足りなくならないように多めに設定します。
これでsignature以外のUserOperationは準備できました。
op1 = { "sender": contract_address1, "nonce": nonce1, "initCode": _init_code1, "callData": call_data1, "callGasLimit": 20000000, "verificationGasLimit": 20000000, "preVerificationGas": 5000000, "maxFeePerGas": web3.toWei("10", "gwei"), "maxPriorityFeePerGas": web3.toWei("5", "gwei"), "paymasterAndData": paymaster_address, } |
あとはsignatureを求めます。まずUserOperationHashを求めてそれに署名します。UserOperationHashはsignature以外のUserOperationとEntryPointのaddress、chain idで計算できます。
def get_user_operation_hash(op, entry_point_address, chain_id): packed = encode_abi( [ "address", "uint256", "bytes32", "bytes32", "uint256", "uint256", "uint256", "uint256", "uint256", "bytes32", ], [ op["sender"], op["nonce"], Web3.keccak(hexstr=op["initCode"]), Web3.keccak(hexstr=op["callData"]), op["callGasLimit"], op["verificationGasLimit"], op["preVerificationGas"], op["maxFeePerGas"], op["maxPriorityFeePerGas"], Web3.keccak(hexstr=op["paymasterAndData"]), ], ) enc = encode_abi(["bytes32", "address", "uint256"], [Web3.keccak(packed), entry_point_address, chain_id]) return Web3.keccak(enc) op_hash1 = get_user_operation_hash(op1, entry_point_address, web3.eth.chain_id) |
ここの実装はこちらを参考にしています。hash関数は似たものが色々あり、hash化の仕方もややこしくて自分はここでとても苦戦しました。
一例として似ているものを並べてみました。
from eth_abi import encode_abi from eth_abi.packed import encode_abi_packed from eth_account import Account from eth_account.messages import defunct_hash_message, encode_defunct from web3 import Web3 web3 = Web3(Web3.HTTPProvider("http://127.0.0.1:8545", request_kwargs={"timeout": 60})) print(Web3.keccak(encode_abi(["bool", "uint256"], [True, 100]))) # b'\x84\xd3\x82\x88\xbf\xc2\xd0\xdb\xff\xb9S(.H4\xa7*\x7f\xd5\xe9\xf8\x00\rF\x13\x9e\x84\x90\xd6;\x138' print(Web3.keccak(encode_abi_packed(["bool", "uint256"], [True, 100]))) # b"\xe4\xab\x7f+`\xaa\x91`\xf82\x87$\xa4\x19\xa7\x90\xb6\xd7LT\x85\x81']\xe7I\xfa\xb9)\xefd9" print(Web3.solidityKeccak(["bool", "uint256"], [True, 100])) # b"\xe4\xab\x7f+`\xaa\x91`\xf82\x87$\xa4\x19\xa7\x90\xb6\xd7LT\x85\x81']\xe7I\xfa\xb9)\xefd9" |
encode_abi_packed()と encode_abi()は似ていますが計算結果が異なります。 Web3.solidityKeccak())は Web3.keccak(encode_abi_packed())と同じですが、今回正しいのは Web3.keccak(encode_abi())なので Web3.solidityKeccak())は使えません。自分は「Solidity」と付いていたので迂闊にこの関数を使ってしまったのですが、違っていたので気をつけてください。
test = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" print(Account.signHash(defunct_hash_message(hexstr=test), private_key=test).signature.hex()) # 0x8647baafd205688055fff577f84b13cd3a23155945b07163da7730aaaaa581263ef514ce9e133ec182aa6571e64b2e80ed313f4ecc81ac377f7c06aa220c31351b print(Account.signHash(test, private_key=test).signature.hex()) # 0xb9b6b2ce616d2750717d1a91b1bc1575187a9e5e191fc15c7cb92849e235308e3f30c0aabbe74000b32c3ace6f9e5eb7bd02ab2eb0f89d953c0244e944e8017d1b print(web3.eth.account.sign_message(encode_defunct(hexstr=test), private_key=test).signature.hex()) # 0x8647baafd205688055fff577f84b13cd3a23155945b07163da7730aaaaa581263ef514ce9e133ec182aa6571e64b2e80ed313f4ecc81ac377f7c06aa220c31351b |
また、 Account.signHash()と web3.eth.account.sign_message()は同じ結果になります。 Account.signHash()はhexのstrを指定する場合、 defunct_hash_message()で一度変換していないと結果が変わります。
UserOperationのhash化はEntryPointとAccountの実装と擦り合わせる必要があります。
何はともあれUserOperationHashは完成したのであとはこれに署名するだけです。署名者はSmart Contract Accountの validateUserOp()で承認する想定のEOAです。
def get_signature(web3, op_hash, private_key): encoded_message3 = web3.eth.account.sign_message( encode_defunct(primitive=op_hash), private_key=private_key, ) return encoded_message3.signature.hex() op1.update({"signature": get_signature(web3, op_hash1, private_key)}) |
handleOps()の第1引数はtupleのlistなのでその形で handle_opsに格納します。
def get_op_tuple(op): return ( op["sender"], op["nonce"], op["initCode"], op["callData"], op["callGasLimit"], op["verificationGasLimit"], op["preVerificationGas"], op["maxFeePerGas"], op["maxPriorityFeePerGas"], op["paymasterAndData"], op["signature"], ) handle_ops.append(get_op_tuple(op1)) |
2. Account2を作成、Account2宛にmintするUserOperationの作成
だいたいは1つ目と同じなので部分的に説明します。
新しくAccountを作成するのでsaltは1つ目のsalt + 1です。
salt2 = salt1 + 1 |
今回はAccountの execute()を介してNFTの mint()を実行するためのcallDataを作成します。callDataの形式は以下です。
callData = "<関数名のhash値4バイト><引数32バイトずつ>" |
execute()は第1引数(dest)でターゲットとなるNFTのcontract addressを、第2引数で送金額を(今回は0です)、第3引数(func)で実行したい関数名と引数をencodeしたものを取ります。そのため、まず mintの実行に関してencodeし、その結果を用いて executeについてencodeするという2重の構造になっています。
mint_call_data = nft_contract.encodeABI( fn_name="mint", kwargs={"to": contract_address2}, ) call_data2 = account_contract.encodeABI( fn_name="execute", kwargs={ "dest": nft_contract_address, "value": 0, "func": mint_call_data, }, ) |
1つ目のUserOperationとsenderが変わるので新しいsenderについてnonceを取得します。nonce1も同様ですが、わざわざこのように書かなくても0とわかっているので0を直接指定してもOKです。
nonce2 = entry_point_contract.functions.getNonce(contract_address2, 0).call() |
3. Account2→Account1にtransferするためのUserOperationを作成
3つ目は新しいAccountをdeployしないのでinitCodeは 0xです。
_init_code3 = "0x" |
callDataはmintと同じ要領でsafeTransferFrom用にします。
transfer_call_data = nft_contract.encodeABI( fn_name="safeTransferFrom", kwargs={ "from": contract_address2, "to": contract_address1, "tokenId": 1, }, ) call_data3 = account_contract.encodeABI( fn_name="execute", kwargs={ "dest": nft_contract_address, "value": 0, "func": transfer_call_data, }, ) |
nonceは2つ目のUserOperationとsenderが同じなのでそれに+1します。
nonce3 = nonce2 + 1 |
4. handleOps()の実行
ここは通常のトランザクション実行と変わらないと思います。
construct_txn = entry_point_contract.functions.handleOps(handle_ops, admin_address).buildTransaction( transaction_options ) signed_txn = web3.eth.account.sign_transaction(construct_txn, private_key) txhash = web3.eth.send_raw_transaction(signed_txn.rawTransaction) receipt = web3.eth.wait_for_transaction_receipt(txhash, timeout=60) print(f"Receipt: {receipt}") print(f"contract_address1: {contract_address1}") print(f"contract_address2: {contract_address2}") |
実行していくのですが、Gasを多めに設定したため、自分の環境だとGanache起動時にCall Gas LimitとBlock Gas Limitも多く設定する必要があっため次のようにGanacheを起動しました。
$ ganache --wallet.accounts="<PRIVATEKEY>,900000000000000000000000" --db=/var/ganache --chain.networkId=5777 --gasLimit=550000000 --miner.callGasLimit=550000000 |
5. 結果
UserOperationの実行結果については注意点がありました。
通常のトランザクションがrevertされる等で失敗した場合は先ほど実装した buildTransactionや send_raw_transactionで例外が投げられます。しかし、UserOperationのcallDataの実行で失敗した場合は正常終了します。そしてトランザクションのReceiptでstatusが取れますが、それもsuccessとなります。
PolygonScanではcallDataの実行に失敗したトランザクションは次のようになります。
UserOperationの実行に失敗した場合、eventとしてはUserOperationRevertReasonが取れるのでそれを使うとcallDataの実行に成功したかが判断できると思います。
今回はTruffleを使ってtokenのownerが意図したものになっているかを確認することで結果をチェックしようと思います。使用するaddressは次の通りです。
contract_address1: 0x4Fb6124B2D1849Fc44477314D4dd9505F58D83e1 contract_address2: 0xBcfD0ba55876c18Dd176fc7173EeC7FF24147E5f nft_contract_address: 0xc4f72dccd4C56642322cdEaBCCb45Dd2A89E49ff entry_point_address: 0x117B5257A9f234b77FaB698878763344dA4E1436 |
Truffleのconsoleから確認しました。
% npx truffle console --network development |
まずはdeployされたはずの2つのAccountコントラクトが存在しているか確認します。
truffle(development)> var account1 = await Account.at("0x4Fb6124B2D1849Fc44477314D4dd9505F58D83e1"); undefined truffle(development)> var account2 = await Account.at("0xBcfD0ba55876c18Dd176fc7173EeC7FF24147E5f"); undefined truffle(development)> account1.entryPoint() '0x117B5257A9f234b77FaB698878763344dA4E1436' truffle(development)> account2.entryPoint() '0x117B5257A9f234b77FaB698878763344dA4E1436' |
EntryPointの値が正しく取れているので問題なさそうです。
次にNFTのmintとtransferに成功したか見てみます。token_id=1のownerが最終的にAccount1( 0x4Fb6124B2D1849Fc44477314D4dd9505F58D83e1)であればOKです。
truffle(development)> var nft = await NFT.at("0xc4f72dccd4C56642322cdEaBCCb45Dd2A89E49ff"); undefined truffle(development)> nft.ownerOf(1) '0x4Fb6124B2D1849Fc44477314D4dd9505F58D83e1' |
上手くいっていそうです 👏
今回実行した内容を少し振り返ってみます。
NFTのコントラクトから見ると、mintやtransferを実行したのはAccount2であり、コントラクトということになります。まさにAccount Abstractionです。ただ、実際にはAccount2を介してmintやtransferを実行したのはEntryPointで、トランザクションを発行したのは署名したEOAであり、ERC4337が完全にはAccount Abstractionを実現しているわけではないことがよくわかります。
参考
- 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
- Get User Operation Hash
- account-abstraction
Appendix
今回実装したコード全体を載せておきます。
import json from eth_abi import encode_abi from eth_account.messages import encode_defunct from web3 import Web3 nft_json_file_path = "./nft.json" account_json_file_path = "./account.json" account_factory_json_file_path = "./account-factory.json" entry_point_json_file_path = "./entry-point.json" def main(): entry_point_address = "0x" paymaster_address = "0x" account_factory_address = "0x" admin_address = "0x" private_key = "" nft_contract_address = "0x" web3 = Web3(Web3.HTTPProvider("http://127.0.0.1:8545", request_kwargs={"timeout": 60})) transaction_options = { "from": admin_address, "nonce": web3.eth.getTransactionCount(admin_address), "gas": 500000000, } handle_ops = [] entry_point_contract_json = get_json_file_as_dict(entry_point_json_file_path) account_contract_json = get_json_file_as_dict(account_json_file_path) account_factory_contract_json = get_json_file_as_dict(account_factory_json_file_path) nft_contract_json = get_json_file_as_dict(nft_json_file_path) entry_point_contract = web3.eth.contract(address=entry_point_address, abi=entry_point_contract_json["abi"]) account_contract = web3.eth.contract(abi=account_contract_json["abi"], bytecode=account_contract_json["bytecode"]) account_factory_contract = web3.eth.contract( address=account_factory_address, abi=account_factory_contract_json["abi"] ) nft_contract = web3.eth.contract(address=nft_contract_address, abi=nft_contract_json["abi"]) # =========== 1 =========== salt1 = account_factory_contract.functions.getLength().call() constructor_args1 = { "anEntryPoint_": entry_point_address, "salt_": salt1, "admin_": admin_address, } init_code1 = account_factory_contract.encodeABI( fn_name="deploy", kwargs=constructor_args1, ) _init_code1 = account_factory_address + init_code1.split("0x")[-1] contract_address1 = get_new_address(account_factory_contract, constructor_args1) call_data1 = "0x" nonce1 = entry_point_contract.functions.getNonce(contract_address1, 0).call() op1 = { "sender": contract_address1, "nonce": nonce1, "initCode": _init_code1, "callData": call_data1, "callGasLimit": 20000000, "verificationGasLimit": 20000000, "preVerificationGas": 5000000, "maxFeePerGas": web3.toWei("10", "gwei"), "maxPriorityFeePerGas": web3.toWei("5", "gwei"), "paymasterAndData": paymaster_address, } op_hash1 = get_user_operation_hash(op1, entry_point_address, web3.eth.chain_id) op1.update({"signature": get_signature(web3, op_hash1, private_key)}) handle_ops.append(get_op_tuple(op1)) # =========== 2 =========== salt2 = salt1 + 1 constructor_args2 = { "anEntryPoint_": entry_point_address, "salt_": salt2, "admin_": admin_address, } init_code2 = account_factory_contract.encodeABI( fn_name="deploy", kwargs=constructor_args2, ) _init_code2 = account_factory_address + init_code2.split("0x")[-1] contract_address2 = get_new_address(account_factory_contract, constructor_args2) mint_call_data = nft_contract.encodeABI( fn_name="mint", kwargs={"to": contract_address2}, ) call_data2 = account_contract.encodeABI( fn_name="execute", kwargs={ "dest": nft_contract_address, "value": 0, "func": mint_call_data, }, ) nonce2 = entry_point_contract.functions.getNonce(contract_address2, 0).call() op2 = { "sender": contract_address2, "nonce": nonce2, "initCode": _init_code2, "callData": call_data2, "callGasLimit": 20000000, "verificationGasLimit": 20000000, "preVerificationGas": 5000000, "maxFeePerGas": web3.toWei("10", "gwei"), "maxPriorityFeePerGas": web3.toWei("5", "gwei"), "paymasterAndData": paymaster_address, } op_hash2 = get_user_operation_hash(op2, entry_point_address, web3.eth.chain_id) op2.update({"signature": get_signature(web3, op_hash2, private_key)}) handle_ops.append(get_op_tuple(op2)) # =========== 3 =========== _init_code3 = "0x" transfer_call_data = nft_contract.encodeABI( fn_name="safeTransferFrom", kwargs={ "from": contract_address2, "to": contract_address1, "tokenId": 4, }, ) call_data3 = account_contract.encodeABI( fn_name="execute", kwargs={ "dest": nft_contract_address, "value": 0, "func": transfer_call_data, }, ) nonce3 = nonce2 + 1 op3 = { "sender": contract_address2, "nonce": nonce3, "initCode": _init_code3, "callData": call_data3, "callGasLimit": 20000000, "verificationGasLimit": 20000000, "preVerificationGas": 5000000, "maxFeePerGas": web3.toWei("10", "gwei"), "maxPriorityFeePerGas": web3.toWei("5", "gwei"), "paymasterAndData": paymaster_address, } op_hash3 = get_user_operation_hash(op3, entry_point_address, web3.eth.chain_id) op3.update({"signature": get_signature(web3, op_hash3, private_key)}) handle_ops.append(get_op_tuple(op3)) construct_txn = entry_point_contract.functions.handleOps(handle_ops, admin_address).buildTransaction( transaction_options ) signed_txn = web3.eth.account.sign_transaction(construct_txn, private_key) txhash = web3.eth.send_raw_transaction(signed_txn.rawTransaction) receipt = web3.eth.wait_for_transaction_receipt(txhash, timeout=60) print(f"Receipt: {receipt}") print(f"contract_address1: {contract_address1}") print(f"contract_address2: {contract_address2}") def get_user_operation_hash(op, entry_point_address, chain_id): packed = encode_abi( [ "address", "uint256", "bytes32", "bytes32", "uint256", "uint256", "uint256", "uint256", "uint256", "bytes32", ], [ op["sender"], op["nonce"], Web3.keccak(hexstr=op["initCode"]), Web3.keccak(hexstr=op["callData"]), op["callGasLimit"], op["verificationGasLimit"], op["preVerificationGas"], op["maxFeePerGas"], op["maxPriorityFeePerGas"], Web3.keccak(hexstr=op["paymasterAndData"]), ], ) enc = encode_abi(["bytes32", "address", "uint256"], [Web3.keccak(packed), entry_point_address, chain_id]) return Web3.keccak(enc) def get_signature(web3, op_hash, private_key): encoded_message3 = web3.eth.account.sign_message( encode_defunct(primitive=op_hash), private_key=private_key, ) return encoded_message3.signature.hex() def get_new_address(account_factory_contract, constructor_args): return account_factory_contract.functions.getNewAddress( constructor_args["anEntryPoint_"], constructor_args["salt_"], constructor_args["admin_"], ).call() def get_op_tuple(op): return ( op["sender"], op["nonce"], op["initCode"], op["callData"], op["callGasLimit"], op["verificationGasLimit"], op["preVerificationGas"], op["maxFeePerGas"], op["maxPriorityFeePerGas"], op["paymasterAndData"], op["signature"], ) def get_json_file_as_dict(file_path): with open(file_path, "r") as file_json: content = json.load(file_json) return content if __name__ == "__main__": main() |