Skip to content

Commit

Permalink
Merge pull request #8 from gnarvaja/support-aa-bundler
Browse files Browse the repository at this point in the history
Support for sending transactions through ERC-4337 bundler
  • Loading branch information
gnarvaja authored Aug 30, 2024
2 parents d7a5b8c + bc6c52a commit 3c27369
Show file tree
Hide file tree
Showing 6 changed files with 500 additions and 44 deletions.
9 changes: 2 additions & 7 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ exclude =
# `pip install eth-prototype[PDF]` like:
# PDF = ReportLab; RXP
web3 =
web3>=6
web3==6.*

defender =
boto3
Expand All @@ -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]
Expand Down
234 changes: 234 additions & 0 deletions src/ethproto/aa_bundler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import random
from warnings import warn
from enum import Enum
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)
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):
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["maxPriorityFeePerGas"], user_operation["maxFeePerGas"]),
"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 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"
+ 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(make_nonce(nonce_key, nonce)),
"callData": call_data,
"signature": dummy_signature,
}

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
)
# 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:
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"]
25 changes: 7 additions & 18 deletions src/ethproto/build_artifacts.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Helper classes to use hardhat build artifacts from python"""


import json
import os
import os.path
Expand All @@ -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
)
Expand All @@ -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

Expand Down Expand Up @@ -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]

Expand All @@ -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]
12 changes: 11 additions & 1 deletion src/ethproto/w3wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,19 @@ 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
# 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:
raise RuntimeError(f"Unknown W3_TRANSACT_MODE {W3_TRANSACT_MODE}")

return provider.w3.eth.wait_for_transaction_receipt(tx_hash)

Expand Down
Loading

0 comments on commit 3c27369

Please sign in to comment.