1+ import time
2+
13from web3 import Web3
24
35TRANSACTION_TIMEOUT = 300
6+ REPLACEMENT_UNDERPRICED_RETRY_DELAY = 5
7+ REPLACEMENT_GAS_BUMP_RATIO = 1.2
8+
9+
10+ def _validate_nonce (nonce ) -> int :
11+ """Validate and return nonce. Raises ValueError if invalid."""
12+ if not isinstance (nonce , int ) or nonce < 0 :
13+ raise ValueError (
14+ f"Invalid nonce value: { nonce } . Nonce must be a non-negative integer."
15+ )
16+ return nonce
17+
18+
19+ def _get_transaction_options (
20+ web3 : Web3 ,
21+ account ,
22+ tx_options : dict ,
23+ * ,
24+ nonce_override : int | None = None ,
25+ bump_gas : bool = False ,
26+ ) -> dict :
27+ """
28+ Build the transaction options dict (from, nonce, value, gas).
29+ Used for both encodedTxDataOnly and send path.
30+ """
31+ opts = {"from" : account .address }
32+
33+ # Nonce: use override (retry), explicit from tx_options, or fetch from chain
34+ if nonce_override is not None :
35+ opts ["nonce" ] = nonce_override
36+ elif "nonce" in tx_options :
37+ opts ["nonce" ] = _validate_nonce (tx_options ["nonce" ])
38+ else :
39+ opts ["nonce" ] = web3 .eth .get_transaction_count (account .address )
40+
41+ if "value" in tx_options :
42+ opts ["value" ] = tx_options ["value" ]
43+
44+ # Gas: bump for replacement, or use tx_options
45+ if bump_gas :
46+ try :
47+ opts ["gasPrice" ] = int (
48+ web3 .eth .gas_price * REPLACEMENT_GAS_BUMP_RATIO
49+ )
50+ except Exception :
51+ opts ["gasPrice" ] = web3 .to_wei (2 , "gwei" )
52+ else :
53+ if "gasPrice" in tx_options :
54+ opts ["gasPrice" ] = web3 .to_wei (tx_options ["gasPrice" ], "gwei" )
55+ if "maxFeePerGas" in tx_options :
56+ opts ["maxFeePerGas" ] = tx_options ["maxFeePerGas" ]
57+
58+ return opts
59+
60+
61+ def _is_retryable_send_error (exc : Exception ) -> bool :
62+ """True if we should retry send (same nonce, higher gas)."""
63+ msg = str (exc ).lower ()
64+ return (
65+ "replacement transaction underpriced" in msg
66+ or "nonce too low" in msg
67+ )
68+
69+
70+ def _send_one (
71+ web3 : Web3 ,
72+ account ,
73+ client_function ,
74+ client_args : tuple ,
75+ tx_options : dict ,
76+ transaction_options : dict ,
77+ ) -> dict :
78+ """Build, sign, send one transaction. No retry."""
79+ transaction = client_function (* client_args , transaction_options )
80+ signed_txn = account .sign_transaction (transaction )
81+ tx_hash = web3 .eth .send_raw_transaction (signed_txn .raw_transaction )
82+
83+ if not tx_options .get ("wait_for_receipt" , True ):
84+ return {"tx_hash" : tx_hash .hex ()}
85+
86+ timeout = tx_options .get ("timeout" , TRANSACTION_TIMEOUT )
87+ tx_receipt = web3 .eth .wait_for_transaction_receipt (tx_hash , timeout = timeout )
88+ return {"tx_hash" : tx_hash .hex (), "tx_receipt" : tx_receipt }
489
590
691def build_and_send_transaction (
@@ -13,6 +98,9 @@ def build_and_send_transaction(
1398 """
1499 Builds and sends a transaction using the provided client function and arguments.
15100
101+ On "replacement transaction underpriced" or "nonce too low", retries once
102+ after a short delay with the same nonce and higher gas.
103+
16104 :param web3 Web3: An instance of Web3.
17105 :param account: The account to use for signing the transaction.
18106 :param client_function: The client function to build the transaction.
@@ -29,51 +117,44 @@ def build_and_send_transaction(
29117 or encoded data if encodedTxDataOnly is True.
30118 :raises Exception: If there is an error during the transaction process.
31119 """
32- try :
33- tx_options = tx_options or {}
34-
35- transaction_options = {
36- "from" : account .address ,
37- }
38-
39- if "nonce" in tx_options :
40- nonce = tx_options ["nonce" ]
41- if not isinstance (nonce , int ) or nonce < 0 :
42- raise ValueError (
43- f"Invalid nonce value: { nonce } . Nonce must be a non-negative integer."
44- )
45- transaction_options ["nonce" ] = nonce
46- else :
47- transaction_options ["nonce" ] = web3 .eth .get_transaction_count (
48- account .address
49- )
120+ tx_options = tx_options or {}
121+ client_args = tuple (client_args )
50122
51- if "value" in tx_options :
52- transaction_options ["value" ] = tx_options ["value" ]
123+ # Encode-only path: build options and return encoded data, no send
124+ if tx_options .get ("encodedTxDataOnly" ):
125+ opts = _get_transaction_options (web3 , account , tx_options )
126+ encoded = client_function (* client_args , opts )
127+ return {"encodedTxData" : encoded }
53128
54- if "gasPrice" in tx_options :
55- transaction_options ["gasPrice" ] = web3 .to_wei (
56- tx_options ["gasPrice" ], "gwei"
57- )
58- if "maxFeePerGas" in tx_options :
59- transaction_options ["maxFeePerGas" ] = tx_options ["maxFeePerGas" ]
129+ # Send path: optionally retry once with same nonce + higher gas
130+ used_nonce = None
131+ last_error = None
60132
61- transaction = client_function (* client_args , transaction_options )
133+ for attempt in range (2 ):
134+ opts = _get_transaction_options (
135+ web3 ,
136+ account ,
137+ tx_options ,
138+ nonce_override = used_nonce ,
139+ bump_gas = (attempt == 1 ),
140+ )
141+ if used_nonce is None :
142+ used_nonce = opts ["nonce" ]
62143
63- if tx_options .get ("encodedTxDataOnly" ):
64- return {"encodedTxData" : transaction }
65-
66- signed_txn = account .sign_transaction (transaction )
67- tx_hash = web3 .eth .send_raw_transaction (signed_txn .raw_transaction )
68-
69- wait_for_receipt = tx_options .get ("wait_for_receipt" , True )
70-
71- if wait_for_receipt :
72- timeout = tx_options .get ("timeout" , TRANSACTION_TIMEOUT )
73- tx_receipt = web3 .eth .wait_for_transaction_receipt (tx_hash , timeout = timeout )
74- return {"tx_hash" : tx_hash .hex (), "tx_receipt" : tx_receipt }
75- else :
76- return {"tx_hash" : tx_hash .hex ()}
144+ try :
145+ return _send_one (
146+ web3 ,
147+ account ,
148+ client_function ,
149+ client_args ,
150+ tx_options ,
151+ opts ,
152+ )
153+ except Exception as e :
154+ last_error = e
155+ if not _is_retryable_send_error (e ):
156+ raise
157+ if attempt == 0 :
158+ time .sleep (REPLACEMENT_UNDERPRICED_RETRY_DELAY )
77159
78- except Exception as e :
79- raise e
160+ raise last_error
0 commit comments