Skip to content

Commit bc6c52a

Browse files
committed
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.
1 parent 77251ce commit bc6c52a

File tree

3 files changed

+248
-29
lines changed

3 files changed

+248
-29
lines changed

src/ethproto/aa_bundler.py

+124-16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import random
2+
from warnings import warn
3+
from enum import Enum
14
import requests
25
from environs import Env
36
from eth_abi import encode
@@ -7,11 +10,44 @@
710
from web3 import Web3
811
from .contracts import RevertError
912

13+
1014
env = Env()
1115

1216
AA_BUNDLER_SENDER = env.str("AA_BUNDLER_SENDER", None)
1317
AA_BUNDLER_ENTRYPOINT = env.str("AA_BUNDLER_ENTRYPOINT", "0x0000000071727De22E5E9d8BAf0edAc6f37da032")
1418
AA_BUNDLER_EXECUTOR_PK = env.str("AA_BUNDLER_EXECUTOR_PK", None)
19+
AA_BUNDLER_PROVIDER = env.str("AA_BUNDLER_PROVIDER", "alchemy")
20+
21+
NonceMode = Enum(
22+
"NonceMode",
23+
[
24+
"RANDOM_KEY", # first time initializes a random key and increments nonce locally with calling the blockchain
25+
"FIXED_KEY_LOCAL_NONCE", # uses a fixed key, keeps nonce locally and fetches the nonce when receiving
26+
# 'AA25 invalid account nonce'
27+
"FIXED_KEY_FETCH_ALWAYS", # uses a fixed key, always fetches unless received as parameter
28+
],
29+
)
30+
31+
AA_BUNDLER_NONCE_MODE = env.enum("AA_BUNDLER_NONCE_MODE", default="FIXED_KEY_LOCAL_NONCE", type=NonceMode)
32+
AA_BUNDLER_NONCE_KEY = env.int("AA_BUNDLER_NONCE_KEY", 0)
33+
AA_BUNDLER_MAX_GETNONCE_RETRIES = env.int("AA_BUNDLER_MAX_GETNONCE_RETRIES", 3)
34+
35+
36+
GET_NONCE_ABI = [
37+
{
38+
"inputs": [
39+
{"internalType": "address", "name": "sender", "type": "address"},
40+
{"internalType": "uint192", "name": "key", "type": "uint192"},
41+
],
42+
"name": "getNonce",
43+
"outputs": [{"internalType": "uint256", "name": "nonce", "type": "uint256"}],
44+
"stateMutability": "view",
45+
"type": "function",
46+
}
47+
]
48+
49+
NONCE_CACHE = {}
50+
RANDOM_NONCE_KEY = None
1551

1652

1753
def pack_two(a, b):
@@ -37,7 +73,7 @@ def pack_user_operation(user_operation):
3773
"callData": user_operation["callData"],
3874
"accountGasLimits": pack_two(user_operation["verificationGasLimit"], user_operation["callGasLimit"]),
3975
"preVerificationGas": _to_uint(user_operation["preVerificationGas"]),
40-
"gasFees": pack_two(user_operation["maxFeePerGas"], user_operation["maxPriorityFeePerGas"]),
76+
"gasFees": pack_two(user_operation["maxPriorityFeePerGas"], user_operation["maxFeePerGas"]),
4177
"paymasterAndData": "0x",
4278
"signature": "0x",
4379
}
@@ -81,8 +117,64 @@ def sign_user_operation(private_key, user_operation, chain_id, entry_point):
81117
return signature.signature.hex()
82118

83119

84-
def send_transaction(w3, tx):
85-
nonce = 0
120+
def make_nonce(nonce_key, nonce):
121+
nonce_key = _to_uint(nonce_key)
122+
nonce = _to_uint(nonce)
123+
return (nonce_key << 64) | nonce
124+
125+
126+
def fetch_nonce(w3, account, entry_point, nonce_key):
127+
ep = w3.eth.contract(abi=GET_NONCE_ABI, address=entry_point)
128+
return ep.functions.getNonce(account, nonce_key).call()
129+
130+
131+
def get_random_nonce_key():
132+
global RANDOM_NONCE_KEY
133+
if RANDOM_NONCE_KEY is None:
134+
RANDOM_NONCE_KEY = random.randint(1, 2**192 - 1)
135+
return RANDOM_NONCE_KEY
136+
137+
138+
def get_nonce_and_key(w3, tx, nonce_mode, entry_point=AA_BUNDLER_ENTRYPOINT, fetch=False):
139+
nonce_key = tx.get("nonceKey", None)
140+
nonce = tx.get("nonce", None)
141+
142+
if nonce_key is None:
143+
if nonce_mode == NonceMode.RANDOM_KEY:
144+
nonce_key = get_random_nonce_key()
145+
else:
146+
nonce_key = AA_BUNDLER_NONCE_KEY
147+
148+
if nonce is None:
149+
if fetch or nonce_mode == NonceMode.FIXED_KEY_FETCH_ALWAYS:
150+
nonce = fetch_nonce(w3, tx.get("from", AA_BUNDLER_SENDER), entry_point, nonce_key)
151+
elif nonce_key not in NONCE_CACHE:
152+
nonce = 0
153+
else:
154+
nonce = NONCE_CACHE[nonce_key]
155+
return nonce_key, nonce
156+
157+
158+
def handle_response_error(resp, w3, tx, retry_nonce):
159+
if "AA25" in resp["error"]["message"] and AA_BUNDLER_MAX_GETNONCE_RETRIES > 0:
160+
# Retry fetching the nonce
161+
if retry_nonce == AA_BUNDLER_MAX_GETNONCE_RETRIES:
162+
raise RevertError(resp["error"]["message"])
163+
warn(f'{resp["error"]["message"]} error, I will retry fetching the nonce')
164+
return send_transaction(w3, tx, retry_nonce=(retry_nonce or 0) + 1)
165+
else:
166+
raise RevertError(resp["error"]["message"])
167+
168+
169+
def get_base_fee(w3):
170+
blk = w3.eth.get_block("latest")
171+
return blk["baseFeePerGas"]
172+
173+
174+
def send_transaction(w3, tx, retry_nonce=None):
175+
nonce_key, nonce = get_nonce_and_key(
176+
w3, tx, AA_BUNDLER_NONCE_MODE, entry_point=AA_BUNDLER_ENTRYPOINT, fetch=retry_nonce is not None
177+
)
86178
# "0xb61d27f6" = bytes4 hash of execute(address,uint256,bytes)
87179
call_data = (
88180
"0xb61d27f6"
@@ -94,22 +186,36 @@ def send_transaction(w3, tx):
94186
)
95187
user_operation = {
96188
"sender": tx.get("from", AA_BUNDLER_SENDER),
97-
"nonce": hex(nonce),
189+
"nonce": hex(make_nonce(nonce_key, nonce)),
98190
"callData": call_data,
99191
"signature": dummy_signature,
100192
}
101-
resp = w3.provider.make_request("eth_estimateUserOperationGas", [user_operation, AA_BUNDLER_ENTRYPOINT])
102-
if "error" in resp:
103-
raise RevertError(resp["error"]["message"])
104-
user_operation.update(resp["result"])
105-
106-
resp = w3.provider.make_request("rundler_maxPriorityFeePerGas", [])
107-
if "error" in resp:
108-
raise RevertError(resp["error"]["message"])
109-
max_priority_fee_per_gas = resp["result"]
110193

111-
user_operation["maxFeePerGas"] = max_priority_fee_per_gas
112-
user_operation["maxPriorityFeePerGas"] = max_priority_fee_per_gas
194+
if AA_BUNDLER_PROVIDER == "alchemy":
195+
resp = w3.provider.make_request(
196+
"eth_estimateUserOperationGas", [user_operation, AA_BUNDLER_ENTRYPOINT]
197+
)
198+
if "error" in resp:
199+
return handle_response_error(resp, w3, tx, retry_nonce)
200+
201+
user_operation.update(resp["result"])
202+
203+
resp = w3.provider.make_request("rundler_maxPriorityFeePerGas", [])
204+
if "error" in resp:
205+
raise RevertError(resp["error"]["message"])
206+
max_priority_fee_per_gas = resp["result"]
207+
user_operation["maxPriorityFeePerGas"] = max_priority_fee_per_gas
208+
user_operation["maxFeePerGas"] = hex(_to_uint(max_priority_fee_per_gas) + _to_uint(get_base_fee(w3)))
209+
elif AA_BUNDLER_PROVIDER == "gelato":
210+
user_operation.update(
211+
{
212+
"preVerificationGas": "0x00",
213+
"callGasLimit": "0x00",
214+
"verificationGasLimit": "0x00",
215+
"maxFeePerGas": "0x00",
216+
"maxPriorityFeePerGas": "0x00",
217+
}
218+
)
113219
user_operation["signature"] = sign_user_operation(
114220
AA_BUNDLER_EXECUTOR_PK, user_operation, tx["chainId"], AA_BUNDLER_ENTRYPOINT
115221
)
@@ -121,6 +227,8 @@ def send_transaction(w3, tx):
121227

122228
resp = w3.provider.make_request("eth_sendUserOperation", [user_operation, AA_BUNDLER_ENTRYPOINT])
123229
if "error" in resp:
124-
raise RevertError(resp["error"]["message"])
230+
return handle_response_error(resp, w3, tx, retry_nonce)
125231

232+
# Store nonce in the cache, so next time uses a new nonce
233+
NONCE_CACHE[nonce_key] = nonce + 1
126234
return resp["result"]

src/ethproto/w3wrappers.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,15 @@ def transact(provider, function, tx_kwargs):
108108
elif W3_TRANSACT_MODE == "defender-async":
109109
from .defender_relay import send_transaction
110110

111-
tx_kwargs = {**provider.tx_kwargs, **tx_kwargs}
111+
tx_kwargs |= provider.tx_kwargs
112112
tx = function.build_transaction(tx_kwargs)
113113
return send_transaction(tx)
114114
elif W3_TRANSACT_MODE == "aa-bundler-async":
115115
from .aa_bundler import send_transaction
116116

117-
tx_kwargs = {**provider.tx_kwargs, **tx_kwargs}
117+
tx_kwargs |= provider.tx_kwargs
118+
# To avoid fetching gas and gasPrice in a standard way, when it's not relevant for user ops
119+
tx_kwargs.update(dict(gas=0, gasPrice=0))
118120
tx = function.build_transaction(tx_kwargs)
119121
return send_transaction(provider.w3, tx)
120122
else:

tests/test_aa_bundler.py

+120-11
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from hexbytes import HexBytes
22
from ethproto import aa_bundler
33
from web3.constants import HASH_ZERO
4-
from unittest.mock import MagicMock
4+
from unittest.mock import MagicMock, patch
55

66

77
def test_pack_two():
@@ -21,9 +21,10 @@ def test_pack_two():
2121
CHAIN_ID = 31337
2222
ENTRYPOINT = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
2323

24+
TEST_SENDER = "0x8961423b54f06bf6D57F8dD3dD1184FA6F3aac3f"
2425

2526
user_operation = {
26-
"sender": "0x515f3Db6c4249919B74eA55915969944fEA4B311",
27+
"sender": TEST_SENDER,
2728
"nonce": 0,
2829
"initCode": "0x",
2930
"callData": TEST_CALL_DATA,
@@ -41,7 +42,7 @@ def test_pack_two():
4142

4243
def test_pack_user_operation():
4344
expected = {
44-
"sender": "0x515f3Db6c4249919B74eA55915969944fEA4B311",
45+
"sender": TEST_SENDER,
4546
"nonce": 0,
4647
"initCode": "0x",
4748
"callData": TEST_CALL_DATA,
@@ -57,20 +58,126 @@ def test_pack_user_operation():
5758
def test_hash_packed_user_operation():
5859
packed = aa_bundler.pack_user_operation(user_operation)
5960
hash = aa_bundler.hash_packed_user_operation_only(packed)
60-
assert hash == "0xb3c6cda6d25de5a793bc280200673119f76f92017c97dacd26bc1329771b96a4"
61+
assert hash == "0xa2c19765d18b0d690c05b20061bd23d066201aff1833a51bd28af115fbd4bcd9"
6162
hash = aa_bundler.hash_packed_user_operation(packed, CHAIN_ID, ENTRYPOINT)
62-
assert hash == "0x213b6b5f785983fa3310d6ae06e63ff883915ad5454dd422e15d9778a9e1da48"
63+
assert hash == "0xb365ad4d366e9081718e926912da7a78a2faae592286fda0cc11923bd141b7cf"
6364

6465

6566
def test_sign_user_operation():
6667
signature = aa_bundler.sign_user_operation(TEST_PRIVATE_KEY, user_operation, CHAIN_ID, ENTRYPOINT)
6768
assert (
6869
signature
69-
== "0x9a2e58cbe1d7c79b933c115e6d041fca080c5a1f572b78116c36b956faf9bf660b4fc10f339fd608d11b56072407bb29d311edb3a79f312f6f8375a97692870d1b" # noqa
70+
== "0xb9b872bfe4e90f4628e8ec24879a5b01045f91da8457f3ce2b417d2e5774b508261ec1147a820e75a141cb61b884a78d7e88996ceddafb9a7016cfe7a48a1f4f1b" # noqa
7071
)
7172

7273

73-
def test_send_transaction():
74+
def test_sign_user_operation_gas_diff():
75+
user_operation_2 = dict(user_operation)
76+
user_operation_2["maxPriorityFeePerGas"] -= 1
77+
signature = aa_bundler.sign_user_operation(TEST_PRIVATE_KEY, user_operation_2, CHAIN_ID, ENTRYPOINT)
78+
assert (
79+
signature
80+
== "0x8162479d2dbd18d7fe93a2f51e283021d6e4eae4f57d20cdd553042723a0b0ea690ab3903d45126b0047da08ab53dfdf86656e4f258ac4936ba96a759ccb77f61b" # noqa
81+
)
82+
83+
84+
def test_make_nonce():
85+
assert aa_bundler.make_nonce(0, 0) == 0
86+
assert aa_bundler.make_nonce(0, 1) == 1
87+
assert aa_bundler.make_nonce(1, 1) == (1 << 64) + 1
88+
89+
90+
FAIL_IF_USED = object()
91+
92+
93+
@patch.object(aa_bundler.random, "randint")
94+
@patch.object(aa_bundler, "fetch_nonce")
95+
def test_get_nonce_force_fetch(fetch_nonce_mock, randint_mock):
96+
# Test fetch=True
97+
fetch_nonce_mock.return_value = 123
98+
assert aa_bundler.get_nonce_and_key(
99+
FAIL_IF_USED,
100+
{"nonceKey": 12, "from": TEST_SENDER},
101+
nonce_mode=aa_bundler.NonceMode.FIXED_KEY_LOCAL_NONCE,
102+
fetch=True,
103+
) == (12, 123)
104+
fetch_nonce_mock.assert_called_once_with(FAIL_IF_USED, TEST_SENDER, ENTRYPOINT, 12)
105+
randint_mock.assert_not_called()
106+
107+
108+
@patch.object(aa_bundler.random, "randint")
109+
@patch.object(aa_bundler, "fetch_nonce")
110+
def test_get_nonce_fetch_always_mode(fetch_nonce_mock, randint_mock):
111+
# Test nonce_mode=NonceMode.FIXED_KEY_FETCH_ALWAYS
112+
fetch_nonce_mock.return_value = 111
113+
assert aa_bundler.get_nonce_and_key(
114+
FAIL_IF_USED,
115+
{"nonceKey": 22, "from": TEST_SENDER},
116+
nonce_mode=aa_bundler.NonceMode.FIXED_KEY_FETCH_ALWAYS,
117+
) == (22, 111)
118+
fetch_nonce_mock.assert_called_once_with(FAIL_IF_USED, TEST_SENDER, ENTRYPOINT, 22)
119+
randint_mock.assert_not_called()
120+
fetch_nonce_mock.reset_mock()
121+
122+
123+
@patch.object(aa_bundler.random, "randint")
124+
@patch.object(aa_bundler, "fetch_nonce")
125+
def test_get_nonce_nonce_key_in_tx(fetch_nonce_mock, randint_mock):
126+
# Test nonce_mode=NonceMode.FIXED_KEY_LOCAL_NONCE
127+
assert aa_bundler.get_nonce_and_key(
128+
FAIL_IF_USED,
129+
{"nonceKey": 22, "from": TEST_SENDER},
130+
nonce_mode=aa_bundler.NonceMode.FIXED_KEY_LOCAL_NONCE,
131+
) == (22, 0)
132+
randint_mock.assert_not_called()
133+
fetch_nonce_mock.assert_not_called()
134+
135+
# Same if nonce_mode=NonceMode.RANDOM_KEY but nonceKey in the tx
136+
assert aa_bundler.get_nonce_and_key(
137+
FAIL_IF_USED,
138+
{"nonceKey": 22, "from": TEST_SENDER},
139+
nonce_mode=aa_bundler.NonceMode.RANDOM_KEY,
140+
) == (22, 0)
141+
randint_mock.assert_not_called()
142+
fetch_nonce_mock.assert_not_called()
143+
144+
145+
@patch.object(aa_bundler.random, "randint")
146+
@patch.object(aa_bundler, "fetch_nonce")
147+
def test_get_nonce_random_key_mode(fetch_nonce_mock, randint_mock):
148+
# If nonce_mode=NonceMode.RANDOM_KEY creates a random key and stores it
149+
randint_mock.return_value = 444
150+
assert aa_bundler.get_nonce_and_key(
151+
FAIL_IF_USED,
152+
{"from": TEST_SENDER},
153+
nonce_mode=aa_bundler.NonceMode.RANDOM_KEY,
154+
) == (444, 0)
155+
fetch_nonce_mock.assert_not_called()
156+
randint_mock.assert_called_with(1, 2**192 - 1)
157+
randint_mock.reset_mock()
158+
assert aa_bundler.RANDOM_NONCE_KEY == 444
159+
aa_bundler.RANDOM_NONCE_KEY = None # cleanup
160+
161+
162+
@patch.object(aa_bundler.random, "randint")
163+
@patch.object(aa_bundler, "fetch_nonce")
164+
def test_get_nonce_with_local_cache(fetch_nonce_mock, randint_mock):
165+
with patch.object(aa_bundler, "AA_BUNDLER_NONCE_KEY", new=55), patch.object(
166+
aa_bundler, "NONCE_CACHE", new={55: 33}
167+
):
168+
# Test nonce_mode=NonceMode.FIXED_KEY_LOCAL_NONCE
169+
assert aa_bundler.get_nonce_and_key(
170+
FAIL_IF_USED,
171+
{"from": TEST_SENDER},
172+
nonce_mode=aa_bundler.NonceMode.FIXED_KEY_LOCAL_NONCE,
173+
) == (55, 33)
174+
randint_mock.assert_not_called()
175+
fetch_nonce_mock.assert_not_called()
176+
177+
178+
@patch.object(aa_bundler, "get_base_fee")
179+
def test_send_transaction(get_base_fee_mock):
180+
get_base_fee_mock.return_value = 0
74181
w3 = MagicMock()
75182
w3.eth.chain_id = CHAIN_ID
76183

@@ -91,8 +198,8 @@ def make_request(method, params):
91198
assert params[0] == {
92199
"sender": "0xE8B412158c205B0F605e0FC09dCdA27d3F140FE9",
93200
"nonce": "0x0",
94-
"callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000",
95-
"signature": "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c",
201+
"callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000", # noqa
202+
"signature": "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c", # noqa
96203
}
97204
return {
98205
"jsonrpc": "2.0",
@@ -113,13 +220,13 @@ def make_request(method, params):
113220
assert params[0] == {
114221
"sender": "0xE8B412158c205B0F605e0FC09dCdA27d3F140FE9",
115222
"nonce": "0x0",
116-
"callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000",
223+
"callData": "0xb61d27f60000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000007ace242f32208d836a2245df957c08547059bf45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000", # noqa
117224
"callGasLimit": "0xcbb8",
118225
"verificationGasLimit": "0x13664",
119226
"preVerificationGas": "0xb430",
120227
"maxFeePerGas": "0x7ffffffff",
121228
"maxPriorityFeePerGas": "0x7ffffffff",
122-
"signature": "0x7980544d044bc1202fed7edec96f2fa795ab8670b439935e6bbb5104e95d84ea32af8bff187913ff7eb2b442baab06d0c300273942e312332659ab0a194bbbe81c",
229+
"signature": "0x7980544d044bc1202fed7edec96f2fa795ab8670b439935e6bbb5104e95d84ea32af8bff187913ff7eb2b442baab06d0c300273942e312332659ab0a194bbbe81c", # noqa
123230
}
124231
return {
125232
"jsonrpc": "2.0",
@@ -130,4 +237,6 @@ def make_request(method, params):
130237
w3.provider.make_request.side_effect = make_request
131238

132239
ret = aa_bundler.send_transaction(w3, tx)
240+
get_base_fee_mock.assert_called_once_with(w3)
241+
assert aa_bundler.NONCE_CACHE[0] == 1
133242
assert ret == "0xa950a17ca1ed83e974fb1aa227360a007cb65f566518af117ffdbb04d8d2d524"

0 commit comments

Comments
 (0)