From 5272e696c3c072f654ffeaf2c56dc8c37cc947e7 Mon Sep 17 00:00:00 2001 From: "Guillermo M. Narvaja" Date: Thu, 29 Aug 2024 16:11:51 -0300 Subject: [PATCH 1/3] Support for sending transactions through ERC-4337 bundler Account abstraction support. Work in progress --- setup.cfg | 2 +- src/ethproto/aa_bundler.py | 126 ++++++++++++++++++++++++++++++ src/ethproto/build_artifacts.py | 25 ++---- src/ethproto/w3wrappers.py | 8 ++ tests/test_aa_bundler.py | 133 ++++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 src/ethproto/aa_bundler.py create mode 100644 tests/test_aa_bundler.py diff --git a/setup.cfg b/setup.cfg index 3ebe338..54ca74f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,7 +63,7 @@ exclude = # `pip install eth-prototype[PDF]` like: # PDF = ReportLab; RXP web3 = - web3>=6 + web3==6.* defender = boto3 diff --git a/src/ethproto/aa_bundler.py b/src/ethproto/aa_bundler.py new file mode 100644 index 0000000..57c5b97 --- /dev/null +++ b/src/ethproto/aa_bundler.py @@ -0,0 +1,126 @@ +import requests +from environs import Env +from eth_abi import encode +from eth_account import Account +from eth_account.messages import encode_defunct +from hexbytes import HexBytes +from web3 import Web3 +from .contracts import RevertError + +env = Env() + +AA_BUNDLER_SENDER = env.str("AA_BUNDLER_SENDER", None) +AA_BUNDLER_ENTRYPOINT = env.str("AA_BUNDLER_ENTRYPOINT", "0x0000000071727De22E5E9d8BAf0edAc6f37da032") +AA_BUNDLER_EXECUTOR_PK = env.str("AA_BUNDLER_EXECUTOR_PK", None) + + +def pack_two(a, b): + a = HexBytes(a).hex()[2:] + b = HexBytes(b).hex()[2:] + return "0x" + a.zfill(32) + b.zfill(32) + + +def _to_uint(x): + if isinstance(x, str): + return int(x, 16) + elif isinstance(x, int): + return x + raise RuntimeError(f"Invalid int value {x}") + + +def pack_user_operation(user_operation): + # https://github.com/eth-infinitism/account-abstraction/blob/develop/contracts/interfaces/PackedUserOperation.sol + return { + "sender": user_operation["sender"], + "nonce": _to_uint(user_operation["nonce"]), + "initCode": "0x", + "callData": user_operation["callData"], + "accountGasLimits": pack_two(user_operation["verificationGasLimit"], user_operation["callGasLimit"]), + "preVerificationGas": _to_uint(user_operation["preVerificationGas"]), + "gasFees": pack_two(user_operation["maxFeePerGas"], user_operation["maxPriorityFeePerGas"]), + "paymasterAndData": "0x", + "signature": "0x", + } + + +def hash_packed_user_operation_only(packed_user_op): + # https://github.com/eth-infinitism/account-abstraction/blob/develop/contracts/core/UserOperationLib.sol#L54 + hash_init_code = Web3.solidity_keccak(["bytes"], [packed_user_op["initCode"]]) + hash_call_data = Web3.solidity_keccak(["bytes"], [packed_user_op["callData"]]) + hash_paymaster_and_data = Web3.solidity_keccak(["bytes"], [packed_user_op["paymasterAndData"]]) + return Web3.keccak( + hexstr=encode( + ["address", "uint256", "bytes32", "bytes32", "bytes32", "uint256", "bytes32", "bytes32"], + [ + packed_user_op["sender"], + packed_user_op["nonce"], + hash_init_code, + hash_call_data, + HexBytes(packed_user_op["accountGasLimits"]), + packed_user_op["preVerificationGas"], + HexBytes(packed_user_op["gasFees"]), + hash_paymaster_and_data, + ], + ).hex() + ).hex() + + +def hash_packed_user_operation(packed_user_op, chain_id, entry_point): + return Web3.keccak( + hexstr=encode( + ["bytes32", "address", "uint256"], + [HexBytes(hash_packed_user_operation_only(packed_user_op)), entry_point, chain_id], + ).hex() + ).hex() + + +def sign_user_operation(private_key, user_operation, chain_id, entry_point): + packed_user_op = pack_user_operation(user_operation) + hash = hash_packed_user_operation(packed_user_op, chain_id, entry_point) + signature = Account.sign_message(encode_defunct(hexstr=hash), private_key) + return signature.signature.hex() + + +def send_transaction(w3, tx): + nonce = 0 + # "0xb61d27f6" = bytes4 hash of execute(address,uint256,bytes) + call_data = ( + "0xb61d27f6" + + encode(["address", "uint256", "bytes"], [tx["to"], tx["value"], HexBytes(tx["data"])]).hex() + ) + dummy_signature = ( + "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c" + ) + user_operation = { + "sender": tx.get("from", AA_BUNDLER_SENDER), + "nonce": hex(nonce), + "callData": call_data, + "signature": dummy_signature, + } + resp = w3.provider.make_request("eth_estimateUserOperationGas", [user_operation, AA_BUNDLER_ENTRYPOINT]) + if "error" in resp: + raise RevertError(resp["error"]["message"]) + user_operation.update(resp["result"]) + + resp = w3.provider.make_request("rundler_maxPriorityFeePerGas", []) + if "error" in resp: + raise RevertError(resp["error"]["message"]) + max_priority_fee_per_gas = resp["result"] + + user_operation["maxFeePerGas"] = max_priority_fee_per_gas + user_operation["maxPriorityFeePerGas"] = max_priority_fee_per_gas + user_operation["signature"] = sign_user_operation( + AA_BUNDLER_EXECUTOR_PK, user_operation, tx["chainId"], AA_BUNDLER_ENTRYPOINT + ) + # Remove paymaster related fields + user_operation.pop("paymaster", None) + user_operation.pop("paymasterData", None) + user_operation.pop("paymasterVerificationGasLimit", None) + user_operation.pop("paymasterPostOpGasLimit", None) + + resp = w3.provider.make_request("eth_sendUserOperation", [user_operation, AA_BUNDLER_ENTRYPOINT]) + if "error" in resp: + raise RevertError(resp["error"]["message"]) + + return resp["result"] diff --git a/src/ethproto/build_artifacts.py b/src/ethproto/build_artifacts.py index 010da93..4415829 100644 --- a/src/ethproto/build_artifacts.py +++ b/src/ethproto/build_artifacts.py @@ -1,6 +1,5 @@ """Helper classes to use hardhat build artifacts from python""" - import json import os import os.path @@ -26,17 +25,15 @@ def __init__(self, **kwargs): self.abi = kwargs["abi"] self.bytecode = kwargs["bytecode"] self.deployed_bytecode = kwargs["deployedBytecode"] - self.link_references = kwargs["linkReferences"] - self.deployed_link_references = kwargs["deployedLinkReferences"] + self.link_references = kwargs.get("linkReferences", {}) + self.deployed_link_references = kwargs.get("deployedLinkReferences", {}) def link(self, libraries: dict) -> "Artifact": """Returns a new artifact with the external libraries linked Libraries is a dictionary of the form {library_name: address} """ - bytecode = self._replace_link_references( - self.bytecode, self.link_references, libraries - ) + bytecode = self._replace_link_references(self.bytecode, self.link_references, libraries) deployed_bytecode = self._replace_link_references( self.deployed_bytecode, self.deployed_link_references, libraries ) @@ -55,9 +52,7 @@ def libraries(self) -> Tuple[str, str]: for lib in libs.keys(): yield lib, source - def _replace_link_references( - self, bytecode: str, link_references: dict, libraries: dict - ) -> str: + def _replace_link_references(self, bytecode: str, link_references: dict, libraries: dict) -> str: # remove 0x prefix if present bytecode = bytecode[2:] if bytecode.startswith("0x") else bytecode @@ -115,17 +110,13 @@ def get_artifact(self, contract: str) -> Artifact: if contract not in self._fullpath_cache: for path in self.lookup_paths: - build_artifact_path = ( - path / contract / contract.with_suffix(".json").name - ) + build_artifact_path = path / contract / contract.with_suffix(".json").name if build_artifact_path.exists(): with open(build_artifact_path) as f: self._fullpath_cache[contract] = Artifact(**json.load(f)) if contract not in self._fullpath_cache: - raise FileNotFoundError( - f"Could not find artifact for {contract} on {self.lookup_paths}" - ) + raise FileNotFoundError(f"Could not find artifact for {contract} on {self.lookup_paths}") return self._fullpath_cache[contract] @@ -145,8 +136,6 @@ def get_artifact_by_name(self, contract_name: str) -> Artifact: self._name_cache[contract_name] = Artifact(**json.load(f)) if contract_name not in self._name_cache: - raise FileNotFoundError( - f"Could not find artifact for {contract_name} on {self.lookup_paths}" - ) + raise FileNotFoundError(f"Could not find artifact for {contract_name} on {self.lookup_paths}") return self._name_cache[contract_name] diff --git a/src/ethproto/w3wrappers.py b/src/ethproto/w3wrappers.py index 1d8fd82..183d9bf 100644 --- a/src/ethproto/w3wrappers.py +++ b/src/ethproto/w3wrappers.py @@ -111,6 +111,14 @@ def transact(provider, function, tx_kwargs): tx_kwargs = {**provider.tx_kwargs, **tx_kwargs} tx = function.build_transaction(tx_kwargs) return send_transaction(tx) + elif W3_TRANSACT_MODE == "aa-bundler-async": + from .aa_bundler import send_transaction + + tx_kwargs = {**provider.tx_kwargs, **tx_kwargs} + tx = function.build_transaction(tx_kwargs) + return send_transaction(provider.w3, tx) + else: + raise RuntimeError(f"Unknown W3_TRANSACT_MODE {W3_TRANSACT_MODE}") return provider.w3.eth.wait_for_transaction_receipt(tx_hash) diff --git a/tests/test_aa_bundler.py b/tests/test_aa_bundler.py new file mode 100644 index 0000000..0aa5e58 --- /dev/null +++ b/tests/test_aa_bundler.py @@ -0,0 +1,133 @@ +from hexbytes import HexBytes +from ethproto import aa_bundler +from web3.constants import HASH_ZERO +from unittest.mock import MagicMock + + +def test_pack_two(): + assert aa_bundler.pack_two(0, 0) == HASH_ZERO + assert aa_bundler.pack_two(1, 2) == "0x0000000000000000000000000000000100000000000000000000000000000002" + assert ( + aa_bundler.pack_two("0x1", "0x2") + == "0x0000000000000000000000000000000100000000000000000000000000000002" + ) + assert aa_bundler.pack_two(HexBytes(2), HexBytes(3) == "0x{:032x}{:032x}".format(2, 3)) + + +TEST_CALL_DATA = "0x47e1da2a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000020000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa841740000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa8417400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000044a9059cbb00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c800000000000000000000000000000000000000000000000000000000004c4b40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000000000000000000000000000000000000098968000000000000000000000000000000000000000000000000000000000" # noqa + +# Private key of index=1 of seed phrase ["test"] * 11 + ["junk"] +TEST_PRIVATE_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" +CHAIN_ID = 31337 +ENTRYPOINT = "0x0000000071727De22E5E9d8BAf0edAc6f37da032" + + +user_operation = { + "sender": "0x515f3Db6c4249919B74eA55915969944fEA4B311", + "nonce": 0, + "initCode": "0x", + "callData": TEST_CALL_DATA, + "callGasLimit": 999999, + "verificationGasLimit": 999999, + "preVerificationGas": 999999, + "maxFeePerGas": 1000000000, + "maxPriorityFeePerGas": 1000000000, + "paymaster": "0x0000000000000000000000000000000000000000", + "paymasterData": "0x", + "paymasterVerificationGasLimit": 0, + "paymasterPostOpGasLimit": 0, +} + + +def test_pack_user_operation(): + expected = { + "sender": "0x515f3Db6c4249919B74eA55915969944fEA4B311", + "nonce": 0, + "initCode": "0x", + "callData": TEST_CALL_DATA, + "accountGasLimits": "0x000000000000000000000000000f423f000000000000000000000000000f423f", + "preVerificationGas": 999999, + "gasFees": "0x0000000000000000000000003b9aca000000000000000000000000003b9aca00", + "paymasterAndData": "0x", + "signature": "0x", + } + assert aa_bundler.pack_user_operation(user_operation) == expected + + +def test_hash_packed_user_operation(): + packed = aa_bundler.pack_user_operation(user_operation) + hash = aa_bundler.hash_packed_user_operation_only(packed) + assert hash == "0xb3c6cda6d25de5a793bc280200673119f76f92017c97dacd26bc1329771b96a4" + hash = aa_bundler.hash_packed_user_operation(packed, CHAIN_ID, ENTRYPOINT) + assert hash == "0x213b6b5f785983fa3310d6ae06e63ff883915ad5454dd422e15d9778a9e1da48" + + +def test_sign_user_operation(): + signature = aa_bundler.sign_user_operation(TEST_PRIVATE_KEY, user_operation, CHAIN_ID, ENTRYPOINT) + assert ( + signature + == "0x9a2e58cbe1d7c79b933c115e6d041fca080c5a1f572b78116c36b956faf9bf660b4fc10f339fd608d11b56072407bb29d311edb3a79f312f6f8375a97692870d1b" # noqa + ) + + +def test_send_transaction(): + w3 = MagicMock() + w3.eth.chain_id = CHAIN_ID + + aa_bundler.AA_BUNDLER_EXECUTOR_PK = TEST_PRIVATE_KEY + + tx = { + "value": 0, + "chainId": 137, + "from": "0xE8B412158c205B0F605e0FC09dCdA27d3F140FE9", + "to": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "data": "0x095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", # noqa + } + + def make_request(method, params): + if method == "eth_estimateUserOperationGas": + assert len(params) == 2 + assert params[1] == ENTRYPOINT + assert params[0] == { + "sender": "0xE8B412158c205B0F605e0FC09dCdA27d3F140FE9", + "nonce": "0x0", + "callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000", + "signature": "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c", + } + return { + "jsonrpc": "2.0", + "id": 1, + "result": { + "preVerificationGas": "0xb430", + "callGasLimit": "0xcbb8", + "verificationGasLimit": "0x13664", + "paymasterVerificationGasLimit": None, + }, + } + elif method == "rundler_maxPriorityFeePerGas": + assert len(params) == 0 + return {"jsonrpc": "2.0", "id": 1, "result": "0x7ffffffff"} + elif method == "eth_sendUserOperation": + assert len(params) == 2 + assert params[1] == ENTRYPOINT + assert params[0] == { + "sender": "0xE8B412158c205B0F605e0FC09dCdA27d3F140FE9", + "nonce": "0x0", + "callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000", + "callGasLimit": "0xcbb8", + "verificationGasLimit": "0x13664", + "preVerificationGas": "0xb430", + "maxFeePerGas": "0x7ffffffff", + "maxPriorityFeePerGas": "0x7ffffffff", + "signature": "0x7980544d044bc1202fed7edec96f2fa795ab8670b439935e6bbb5104e95d84ea32af8bff187913ff7eb2b442baab06d0c300273942e312332659ab0a194bbbe81c", + } + return { + "jsonrpc": "2.0", + "id": 1, + "result": "0xa950a17ca1ed83e974fb1aa227360a007cb65f566518af117ffdbb04d8d2d524", + } + + w3.provider.make_request.side_effect = make_request + + ret = aa_bundler.send_transaction(w3, tx) + assert ret == "0xa950a17ca1ed83e974fb1aa227360a007cb65f566518af117ffdbb04d8d2d524" From 77251ce3c2b48c5b9dce5a81df56c2b48f7a8816 Mon Sep 17 00:00:00 2001 From: "Guillermo M. Narvaja" Date: Fri, 30 Aug 2024 06:13:07 -0300 Subject: [PATCH 2/3] Remove testing variant without w3 --- setup.cfg | 7 +------ tox.ini | 22 ++++------------------ 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/setup.cfg b/setup.cfg index 54ca74f..b2adc00 100644 --- a/setup.cfg +++ b/setup.cfg @@ -77,12 +77,7 @@ testing = pytest gmpy2 pytest-cov - -testing-w3 = - web3[tester]>=6 - setuptools - pytest - pytest-cov + web3[tester]==6.* boto3 [options.entry_points] diff --git a/tox.ini b/tox.ini index cd34b04..6201be1 100644 --- a/tox.ini +++ b/tox.ini @@ -4,12 +4,12 @@ [tox] minversion = 3.15 -envlist = {py39,py310}, {py39,py310}-w3 +envlist = {py39,py310} [gh-actions] python = - 3.9: py39, py39-w3 - 3.10: py310, py310-w3 + 3.9: py39 + 3.10: py310 [testenv] description = invoke pytest to run automated tests @@ -20,26 +20,12 @@ setenv = passenv = HOME WADRAY_USE_GMPY2 -extras = - testing -commands = - pytest {posargs} - - -[testenv:{py39,py310}-w3] -description = invoke pytest to run automated tests -isolated_build = True -setenv = - TOXINIDIR = {toxinidir} - TEST_ENV = web3py -passenv = - HOME W3_* TRANSACT_MODE WEB3_PROVIDER_URI WEB3_* extras = - testing-w3 + testing deps = warrant @ git+https://github.com/gnarvaja/warrant.git#egg=warrant commands = From bc6c52a37332725f4863bf62c9c9cfcd75a51363 Mon Sep 17 00:00:00 2001 From: "Guillermo M. Narvaja" Date: Fri, 30 Aug 2024 11:10:58 -0300 Subject: [PATCH 3/3] Nonce management + fixes Nonce Management with different modes. Fixes with respect to the gas, now baseFee is fetch from the blockchain and maxPriorityFee is fetch from Alchemy API. Also, there was an error when packing gasFees. --- src/ethproto/aa_bundler.py | 140 ++++++++++++++++++++++++++++++++----- src/ethproto/w3wrappers.py | 6 +- tests/test_aa_bundler.py | 131 +++++++++++++++++++++++++++++++--- 3 files changed, 248 insertions(+), 29 deletions(-) diff --git a/src/ethproto/aa_bundler.py b/src/ethproto/aa_bundler.py index 57c5b97..457dfca 100644 --- a/src/ethproto/aa_bundler.py +++ b/src/ethproto/aa_bundler.py @@ -1,3 +1,6 @@ +import random +from warnings import warn +from enum import Enum import requests from environs import Env from eth_abi import encode @@ -7,11 +10,44 @@ from web3 import Web3 from .contracts import RevertError + env = Env() AA_BUNDLER_SENDER = env.str("AA_BUNDLER_SENDER", None) AA_BUNDLER_ENTRYPOINT = env.str("AA_BUNDLER_ENTRYPOINT", "0x0000000071727De22E5E9d8BAf0edAc6f37da032") AA_BUNDLER_EXECUTOR_PK = env.str("AA_BUNDLER_EXECUTOR_PK", None) +AA_BUNDLER_PROVIDER = env.str("AA_BUNDLER_PROVIDER", "alchemy") + +NonceMode = Enum( + "NonceMode", + [ + "RANDOM_KEY", # first time initializes a random key and increments nonce locally with calling the blockchain + "FIXED_KEY_LOCAL_NONCE", # uses a fixed key, keeps nonce locally and fetches the nonce when receiving + # 'AA25 invalid account nonce' + "FIXED_KEY_FETCH_ALWAYS", # uses a fixed key, always fetches unless received as parameter + ], +) + +AA_BUNDLER_NONCE_MODE = env.enum("AA_BUNDLER_NONCE_MODE", default="FIXED_KEY_LOCAL_NONCE", type=NonceMode) +AA_BUNDLER_NONCE_KEY = env.int("AA_BUNDLER_NONCE_KEY", 0) +AA_BUNDLER_MAX_GETNONCE_RETRIES = env.int("AA_BUNDLER_MAX_GETNONCE_RETRIES", 3) + + +GET_NONCE_ABI = [ + { + "inputs": [ + {"internalType": "address", "name": "sender", "type": "address"}, + {"internalType": "uint192", "name": "key", "type": "uint192"}, + ], + "name": "getNonce", + "outputs": [{"internalType": "uint256", "name": "nonce", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + } +] + +NONCE_CACHE = {} +RANDOM_NONCE_KEY = None def pack_two(a, b): @@ -37,7 +73,7 @@ def pack_user_operation(user_operation): "callData": user_operation["callData"], "accountGasLimits": pack_two(user_operation["verificationGasLimit"], user_operation["callGasLimit"]), "preVerificationGas": _to_uint(user_operation["preVerificationGas"]), - "gasFees": pack_two(user_operation["maxFeePerGas"], user_operation["maxPriorityFeePerGas"]), + "gasFees": pack_two(user_operation["maxPriorityFeePerGas"], user_operation["maxFeePerGas"]), "paymasterAndData": "0x", "signature": "0x", } @@ -81,8 +117,64 @@ def sign_user_operation(private_key, user_operation, chain_id, entry_point): return signature.signature.hex() -def send_transaction(w3, tx): - nonce = 0 +def make_nonce(nonce_key, nonce): + nonce_key = _to_uint(nonce_key) + nonce = _to_uint(nonce) + return (nonce_key << 64) | nonce + + +def fetch_nonce(w3, account, entry_point, nonce_key): + ep = w3.eth.contract(abi=GET_NONCE_ABI, address=entry_point) + return ep.functions.getNonce(account, nonce_key).call() + + +def get_random_nonce_key(): + global RANDOM_NONCE_KEY + if RANDOM_NONCE_KEY is None: + RANDOM_NONCE_KEY = random.randint(1, 2**192 - 1) + return RANDOM_NONCE_KEY + + +def get_nonce_and_key(w3, tx, nonce_mode, entry_point=AA_BUNDLER_ENTRYPOINT, fetch=False): + nonce_key = tx.get("nonceKey", None) + nonce = tx.get("nonce", None) + + if nonce_key is None: + if nonce_mode == NonceMode.RANDOM_KEY: + nonce_key = get_random_nonce_key() + else: + nonce_key = AA_BUNDLER_NONCE_KEY + + if nonce is None: + if fetch or nonce_mode == NonceMode.FIXED_KEY_FETCH_ALWAYS: + nonce = fetch_nonce(w3, tx.get("from", AA_BUNDLER_SENDER), entry_point, nonce_key) + elif nonce_key not in NONCE_CACHE: + nonce = 0 + else: + nonce = NONCE_CACHE[nonce_key] + return nonce_key, nonce + + +def handle_response_error(resp, w3, tx, retry_nonce): + if "AA25" in resp["error"]["message"] and AA_BUNDLER_MAX_GETNONCE_RETRIES > 0: + # Retry fetching the nonce + if retry_nonce == AA_BUNDLER_MAX_GETNONCE_RETRIES: + raise RevertError(resp["error"]["message"]) + warn(f'{resp["error"]["message"]} error, I will retry fetching the nonce') + return send_transaction(w3, tx, retry_nonce=(retry_nonce or 0) + 1) + else: + raise RevertError(resp["error"]["message"]) + + +def get_base_fee(w3): + blk = w3.eth.get_block("latest") + return blk["baseFeePerGas"] + + +def send_transaction(w3, tx, retry_nonce=None): + nonce_key, nonce = get_nonce_and_key( + w3, tx, AA_BUNDLER_NONCE_MODE, entry_point=AA_BUNDLER_ENTRYPOINT, fetch=retry_nonce is not None + ) # "0xb61d27f6" = bytes4 hash of execute(address,uint256,bytes) call_data = ( "0xb61d27f6" @@ -94,22 +186,36 @@ def send_transaction(w3, tx): ) user_operation = { "sender": tx.get("from", AA_BUNDLER_SENDER), - "nonce": hex(nonce), + "nonce": hex(make_nonce(nonce_key, nonce)), "callData": call_data, "signature": dummy_signature, } - resp = w3.provider.make_request("eth_estimateUserOperationGas", [user_operation, AA_BUNDLER_ENTRYPOINT]) - if "error" in resp: - raise RevertError(resp["error"]["message"]) - user_operation.update(resp["result"]) - - resp = w3.provider.make_request("rundler_maxPriorityFeePerGas", []) - if "error" in resp: - raise RevertError(resp["error"]["message"]) - max_priority_fee_per_gas = resp["result"] - user_operation["maxFeePerGas"] = max_priority_fee_per_gas - user_operation["maxPriorityFeePerGas"] = max_priority_fee_per_gas + if AA_BUNDLER_PROVIDER == "alchemy": + resp = w3.provider.make_request( + "eth_estimateUserOperationGas", [user_operation, AA_BUNDLER_ENTRYPOINT] + ) + if "error" in resp: + return handle_response_error(resp, w3, tx, retry_nonce) + + user_operation.update(resp["result"]) + + resp = w3.provider.make_request("rundler_maxPriorityFeePerGas", []) + if "error" in resp: + raise RevertError(resp["error"]["message"]) + max_priority_fee_per_gas = resp["result"] + user_operation["maxPriorityFeePerGas"] = max_priority_fee_per_gas + user_operation["maxFeePerGas"] = hex(_to_uint(max_priority_fee_per_gas) + _to_uint(get_base_fee(w3))) + elif AA_BUNDLER_PROVIDER == "gelato": + user_operation.update( + { + "preVerificationGas": "0x00", + "callGasLimit": "0x00", + "verificationGasLimit": "0x00", + "maxFeePerGas": "0x00", + "maxPriorityFeePerGas": "0x00", + } + ) user_operation["signature"] = sign_user_operation( AA_BUNDLER_EXECUTOR_PK, user_operation, tx["chainId"], AA_BUNDLER_ENTRYPOINT ) @@ -121,6 +227,8 @@ def send_transaction(w3, tx): resp = w3.provider.make_request("eth_sendUserOperation", [user_operation, AA_BUNDLER_ENTRYPOINT]) if "error" in resp: - raise RevertError(resp["error"]["message"]) + return handle_response_error(resp, w3, tx, retry_nonce) + # Store nonce in the cache, so next time uses a new nonce + NONCE_CACHE[nonce_key] = nonce + 1 return resp["result"] diff --git a/src/ethproto/w3wrappers.py b/src/ethproto/w3wrappers.py index 183d9bf..1a74077 100644 --- a/src/ethproto/w3wrappers.py +++ b/src/ethproto/w3wrappers.py @@ -108,13 +108,15 @@ def transact(provider, function, tx_kwargs): elif W3_TRANSACT_MODE == "defender-async": from .defender_relay import send_transaction - tx_kwargs = {**provider.tx_kwargs, **tx_kwargs} + tx_kwargs |= provider.tx_kwargs tx = function.build_transaction(tx_kwargs) return send_transaction(tx) elif W3_TRANSACT_MODE == "aa-bundler-async": from .aa_bundler import send_transaction - tx_kwargs = {**provider.tx_kwargs, **tx_kwargs} + tx_kwargs |= provider.tx_kwargs + # To avoid fetching gas and gasPrice in a standard way, when it's not relevant for user ops + tx_kwargs.update(dict(gas=0, gasPrice=0)) tx = function.build_transaction(tx_kwargs) return send_transaction(provider.w3, tx) else: diff --git a/tests/test_aa_bundler.py b/tests/test_aa_bundler.py index 0aa5e58..04c63af 100644 --- a/tests/test_aa_bundler.py +++ b/tests/test_aa_bundler.py @@ -1,7 +1,7 @@ from hexbytes import HexBytes from ethproto import aa_bundler from web3.constants import HASH_ZERO -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch def test_pack_two(): @@ -21,9 +21,10 @@ def test_pack_two(): CHAIN_ID = 31337 ENTRYPOINT = "0x0000000071727De22E5E9d8BAf0edAc6f37da032" +TEST_SENDER = "0x8961423b54f06bf6D57F8dD3dD1184FA6F3aac3f" user_operation = { - "sender": "0x515f3Db6c4249919B74eA55915969944fEA4B311", + "sender": TEST_SENDER, "nonce": 0, "initCode": "0x", "callData": TEST_CALL_DATA, @@ -41,7 +42,7 @@ def test_pack_two(): def test_pack_user_operation(): expected = { - "sender": "0x515f3Db6c4249919B74eA55915969944fEA4B311", + "sender": TEST_SENDER, "nonce": 0, "initCode": "0x", "callData": TEST_CALL_DATA, @@ -57,20 +58,126 @@ def test_pack_user_operation(): def test_hash_packed_user_operation(): packed = aa_bundler.pack_user_operation(user_operation) hash = aa_bundler.hash_packed_user_operation_only(packed) - assert hash == "0xb3c6cda6d25de5a793bc280200673119f76f92017c97dacd26bc1329771b96a4" + assert hash == "0xa2c19765d18b0d690c05b20061bd23d066201aff1833a51bd28af115fbd4bcd9" hash = aa_bundler.hash_packed_user_operation(packed, CHAIN_ID, ENTRYPOINT) - assert hash == "0x213b6b5f785983fa3310d6ae06e63ff883915ad5454dd422e15d9778a9e1da48" + assert hash == "0xb365ad4d366e9081718e926912da7a78a2faae592286fda0cc11923bd141b7cf" def test_sign_user_operation(): signature = aa_bundler.sign_user_operation(TEST_PRIVATE_KEY, user_operation, CHAIN_ID, ENTRYPOINT) assert ( signature - == "0x9a2e58cbe1d7c79b933c115e6d041fca080c5a1f572b78116c36b956faf9bf660b4fc10f339fd608d11b56072407bb29d311edb3a79f312f6f8375a97692870d1b" # noqa + == "0xb9b872bfe4e90f4628e8ec24879a5b01045f91da8457f3ce2b417d2e5774b508261ec1147a820e75a141cb61b884a78d7e88996ceddafb9a7016cfe7a48a1f4f1b" # noqa ) -def test_send_transaction(): +def test_sign_user_operation_gas_diff(): + user_operation_2 = dict(user_operation) + user_operation_2["maxPriorityFeePerGas"] -= 1 + signature = aa_bundler.sign_user_operation(TEST_PRIVATE_KEY, user_operation_2, CHAIN_ID, ENTRYPOINT) + assert ( + signature + == "0x8162479d2dbd18d7fe93a2f51e283021d6e4eae4f57d20cdd553042723a0b0ea690ab3903d45126b0047da08ab53dfdf86656e4f258ac4936ba96a759ccb77f61b" # noqa + ) + + +def test_make_nonce(): + assert aa_bundler.make_nonce(0, 0) == 0 + assert aa_bundler.make_nonce(0, 1) == 1 + assert aa_bundler.make_nonce(1, 1) == (1 << 64) + 1 + + +FAIL_IF_USED = object() + + +@patch.object(aa_bundler.random, "randint") +@patch.object(aa_bundler, "fetch_nonce") +def test_get_nonce_force_fetch(fetch_nonce_mock, randint_mock): + # Test fetch=True + fetch_nonce_mock.return_value = 123 + assert aa_bundler.get_nonce_and_key( + FAIL_IF_USED, + {"nonceKey": 12, "from": TEST_SENDER}, + nonce_mode=aa_bundler.NonceMode.FIXED_KEY_LOCAL_NONCE, + fetch=True, + ) == (12, 123) + fetch_nonce_mock.assert_called_once_with(FAIL_IF_USED, TEST_SENDER, ENTRYPOINT, 12) + randint_mock.assert_not_called() + + +@patch.object(aa_bundler.random, "randint") +@patch.object(aa_bundler, "fetch_nonce") +def test_get_nonce_fetch_always_mode(fetch_nonce_mock, randint_mock): + # Test nonce_mode=NonceMode.FIXED_KEY_FETCH_ALWAYS + fetch_nonce_mock.return_value = 111 + assert aa_bundler.get_nonce_and_key( + FAIL_IF_USED, + {"nonceKey": 22, "from": TEST_SENDER}, + nonce_mode=aa_bundler.NonceMode.FIXED_KEY_FETCH_ALWAYS, + ) == (22, 111) + fetch_nonce_mock.assert_called_once_with(FAIL_IF_USED, TEST_SENDER, ENTRYPOINT, 22) + randint_mock.assert_not_called() + fetch_nonce_mock.reset_mock() + + +@patch.object(aa_bundler.random, "randint") +@patch.object(aa_bundler, "fetch_nonce") +def test_get_nonce_nonce_key_in_tx(fetch_nonce_mock, randint_mock): + # Test nonce_mode=NonceMode.FIXED_KEY_LOCAL_NONCE + assert aa_bundler.get_nonce_and_key( + FAIL_IF_USED, + {"nonceKey": 22, "from": TEST_SENDER}, + nonce_mode=aa_bundler.NonceMode.FIXED_KEY_LOCAL_NONCE, + ) == (22, 0) + randint_mock.assert_not_called() + fetch_nonce_mock.assert_not_called() + + # Same if nonce_mode=NonceMode.RANDOM_KEY but nonceKey in the tx + assert aa_bundler.get_nonce_and_key( + FAIL_IF_USED, + {"nonceKey": 22, "from": TEST_SENDER}, + nonce_mode=aa_bundler.NonceMode.RANDOM_KEY, + ) == (22, 0) + randint_mock.assert_not_called() + fetch_nonce_mock.assert_not_called() + + +@patch.object(aa_bundler.random, "randint") +@patch.object(aa_bundler, "fetch_nonce") +def test_get_nonce_random_key_mode(fetch_nonce_mock, randint_mock): + # If nonce_mode=NonceMode.RANDOM_KEY creates a random key and stores it + randint_mock.return_value = 444 + assert aa_bundler.get_nonce_and_key( + FAIL_IF_USED, + {"from": TEST_SENDER}, + nonce_mode=aa_bundler.NonceMode.RANDOM_KEY, + ) == (444, 0) + fetch_nonce_mock.assert_not_called() + randint_mock.assert_called_with(1, 2**192 - 1) + randint_mock.reset_mock() + assert aa_bundler.RANDOM_NONCE_KEY == 444 + aa_bundler.RANDOM_NONCE_KEY = None # cleanup + + +@patch.object(aa_bundler.random, "randint") +@patch.object(aa_bundler, "fetch_nonce") +def test_get_nonce_with_local_cache(fetch_nonce_mock, randint_mock): + with patch.object(aa_bundler, "AA_BUNDLER_NONCE_KEY", new=55), patch.object( + aa_bundler, "NONCE_CACHE", new={55: 33} + ): + # Test nonce_mode=NonceMode.FIXED_KEY_LOCAL_NONCE + assert aa_bundler.get_nonce_and_key( + FAIL_IF_USED, + {"from": TEST_SENDER}, + nonce_mode=aa_bundler.NonceMode.FIXED_KEY_LOCAL_NONCE, + ) == (55, 33) + randint_mock.assert_not_called() + fetch_nonce_mock.assert_not_called() + + +@patch.object(aa_bundler, "get_base_fee") +def test_send_transaction(get_base_fee_mock): + get_base_fee_mock.return_value = 0 w3 = MagicMock() w3.eth.chain_id = CHAIN_ID @@ -91,8 +198,8 @@ def make_request(method, params): assert params[0] == { "sender": "0xE8B412158c205B0F605e0FC09dCdA27d3F140FE9", "nonce": "0x0", - "callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000", - "signature": "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c", + "callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000", # noqa + "signature": "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c", # noqa } return { "jsonrpc": "2.0", @@ -113,13 +220,13 @@ def make_request(method, params): assert params[0] == { "sender": "0xE8B412158c205B0F605e0FC09dCdA27d3F140FE9", "nonce": "0x0", - "callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000", + "callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000", # noqa "callGasLimit": "0xcbb8", "verificationGasLimit": "0x13664", "preVerificationGas": "0xb430", "maxFeePerGas": "0x7ffffffff", "maxPriorityFeePerGas": "0x7ffffffff", - "signature": "0x7980544d044bc1202fed7edec96f2fa795ab8670b439935e6bbb5104e95d84ea32af8bff187913ff7eb2b442baab06d0c300273942e312332659ab0a194bbbe81c", + "signature": "0x7980544d044bc1202fed7edec96f2fa795ab8670b439935e6bbb5104e95d84ea32af8bff187913ff7eb2b442baab06d0c300273942e312332659ab0a194bbbe81c", # noqa } return { "jsonrpc": "2.0", @@ -130,4 +237,6 @@ def make_request(method, params): w3.provider.make_request.side_effect = make_request ret = aa_bundler.send_transaction(w3, tx) + get_base_fee_mock.assert_called_once_with(w3) + assert aa_bundler.NONCE_CACHE[0] == 1 assert ret == "0xa950a17ca1ed83e974fb1aa227360a007cb65f566518af117ffdbb04d8d2d524"