brawny 0.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brawny/__init__.py +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- brawny-0.1.13.dist-info/top_level.txt +1 -0
brawny/tx/replacement.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Stuck transaction detection and replacement.
|
|
2
|
+
|
|
3
|
+
Implements transaction replacement logic from SPEC 9.4:
|
|
4
|
+
- Detect stuck transactions based on time and blocks
|
|
5
|
+
- Calculate replacement fees with proper bumping
|
|
6
|
+
- Create replacement attempts with linked history
|
|
7
|
+
- Enforce max replacement attempts and backoff
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from web3 import Web3
|
|
18
|
+
|
|
19
|
+
from brawny.logging import LogEvents, get_logger
|
|
20
|
+
from brawny.metrics import TX_REPLACED, get_metrics
|
|
21
|
+
from brawny.model.enums import AttemptStatus, IntentStatus
|
|
22
|
+
from brawny.tx.intent import transition_intent
|
|
23
|
+
from brawny.tx.utils import normalize_tx_dict
|
|
24
|
+
from brawny.model.types import GasParams
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from brawny.config import Config
|
|
28
|
+
from brawny.db.base import Database
|
|
29
|
+
from brawny.keystore import Keystore
|
|
30
|
+
from brawny.lifecycle import LifecycleDispatcher
|
|
31
|
+
from brawny.model.types import TxAttempt, TxIntent
|
|
32
|
+
from brawny._rpc.manager import RPCManager
|
|
33
|
+
from brawny.tx.nonce import NonceManager
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ReplacementResult:
|
|
40
|
+
"""Result of a replacement attempt."""
|
|
41
|
+
|
|
42
|
+
success: bool
|
|
43
|
+
new_attempt: TxAttempt | None = None
|
|
44
|
+
new_tx_hash: str | None = None
|
|
45
|
+
error: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TxReplacer:
|
|
49
|
+
"""Handle stuck transaction replacement.
|
|
50
|
+
|
|
51
|
+
Implements SPEC 9.4 replacement policy:
|
|
52
|
+
- Same nonce as original attempt
|
|
53
|
+
- Bump both max_fee_per_gas and max_priority_fee_per_gas by fee_bump_percent
|
|
54
|
+
- Link via replaces_attempt_id
|
|
55
|
+
- Mark old attempt as replaced
|
|
56
|
+
- Max max_replacement_attempts before abandoning
|
|
57
|
+
- Double wait time between each replacement attempt
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
db: Database,
|
|
63
|
+
rpc: RPCManager,
|
|
64
|
+
keystore: Keystore,
|
|
65
|
+
nonce_manager: NonceManager,
|
|
66
|
+
config: Config,
|
|
67
|
+
lifecycle: "LifecycleDispatcher | None" = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Initialize transaction replacer.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
db: Database connection
|
|
73
|
+
rpc: RPC manager for chain queries
|
|
74
|
+
keystore: Keystore for transaction signing
|
|
75
|
+
nonce_manager: Nonce manager
|
|
76
|
+
config: Application configuration
|
|
77
|
+
"""
|
|
78
|
+
self._db = db
|
|
79
|
+
self._rpc = rpc
|
|
80
|
+
self._keystore = keystore
|
|
81
|
+
self._nonce_manager = nonce_manager
|
|
82
|
+
self._config = config
|
|
83
|
+
self._lifecycle = lifecycle
|
|
84
|
+
|
|
85
|
+
def calculate_replacement_fees(self, old_params: GasParams) -> GasParams:
|
|
86
|
+
"""Calculate bumped fees for replacement transaction.
|
|
87
|
+
|
|
88
|
+
Per Ethereum protocol, replacement must have at least 10% higher fees.
|
|
89
|
+
Uses configured fee_bump_percent (default 15%).
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
old_params: Previous gas parameters
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
New gas parameters with bumped fees
|
|
96
|
+
"""
|
|
97
|
+
from brawny.tx.fees import bump_fees
|
|
98
|
+
|
|
99
|
+
return bump_fees(
|
|
100
|
+
old_params,
|
|
101
|
+
bump_percent=self._config.fee_bump_percent,
|
|
102
|
+
max_fee_cap=self._config.max_fee,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def get_replacement_count(self, intent_id) -> int:
|
|
106
|
+
"""Get number of replacement attempts for an intent.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
intent_id: Intent ID
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Number of attempts that are replacements
|
|
113
|
+
"""
|
|
114
|
+
attempts = self._db.get_attempts_for_intent(intent_id)
|
|
115
|
+
return sum(1 for a in attempts if a.replaces_attempt_id is not None)
|
|
116
|
+
|
|
117
|
+
def should_replace(self, intent: TxIntent, attempt: TxAttempt) -> bool:
|
|
118
|
+
"""Check if a transaction should be replaced.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
intent: Transaction intent
|
|
122
|
+
attempt: Current transaction attempt
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
True if transaction should be replaced
|
|
126
|
+
"""
|
|
127
|
+
if not attempt.broadcast_block or not attempt.tx_hash:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
# Check max replacements
|
|
131
|
+
replacement_count = self.get_replacement_count(intent.intent_id)
|
|
132
|
+
if replacement_count >= self._config.max_replacement_attempts:
|
|
133
|
+
logger.info(
|
|
134
|
+
"replacement.max_reached",
|
|
135
|
+
intent_id=str(intent.intent_id),
|
|
136
|
+
count=replacement_count,
|
|
137
|
+
max=self._config.max_replacement_attempts,
|
|
138
|
+
)
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
# Check time elapsed using Unix timestamps (no timezone issues)
|
|
142
|
+
import time
|
|
143
|
+
|
|
144
|
+
if not attempt.broadcast_at:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
elapsed_seconds = time.time() - attempt.broadcast_at.timestamp()
|
|
148
|
+
|
|
149
|
+
# Double wait time for each replacement attempt
|
|
150
|
+
wait_multiplier = 2 ** replacement_count
|
|
151
|
+
required_wait = self._config.stuck_tx_seconds * wait_multiplier
|
|
152
|
+
|
|
153
|
+
if elapsed_seconds < required_wait:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
# Check if still pending (no receipt)
|
|
157
|
+
receipt = self._rpc.get_transaction_receipt(attempt.tx_hash)
|
|
158
|
+
if receipt is not None:
|
|
159
|
+
# Has receipt - don't replace
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
# Check blocks elapsed
|
|
163
|
+
try:
|
|
164
|
+
current_block = self._rpc.get_block_number()
|
|
165
|
+
blocks_since = current_block - attempt.broadcast_block
|
|
166
|
+
|
|
167
|
+
required_blocks = self._config.stuck_tx_blocks * wait_multiplier
|
|
168
|
+
if blocks_since < required_blocks:
|
|
169
|
+
return False
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
def replace_transaction(
|
|
176
|
+
self,
|
|
177
|
+
intent: TxIntent,
|
|
178
|
+
attempt: TxAttempt,
|
|
179
|
+
) -> ReplacementResult:
|
|
180
|
+
"""Create a replacement transaction with bumped fees.
|
|
181
|
+
|
|
182
|
+
Uses the same nonce as the original attempt but with higher fees.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
intent: Transaction intent
|
|
186
|
+
attempt: Current stuck attempt
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
ReplacementResult with new attempt if successful
|
|
190
|
+
"""
|
|
191
|
+
if attempt.tx_hash:
|
|
192
|
+
try:
|
|
193
|
+
receipt = self._rpc.get_transaction_receipt(attempt.tx_hash)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.warning(
|
|
196
|
+
"replacement.receipt_check_failed",
|
|
197
|
+
intent_id=str(intent.intent_id),
|
|
198
|
+
attempt_id=str(attempt.attempt_id),
|
|
199
|
+
tx_hash=attempt.tx_hash,
|
|
200
|
+
error=str(e)[:200],
|
|
201
|
+
)
|
|
202
|
+
receipt = None
|
|
203
|
+
if receipt:
|
|
204
|
+
logger.info(
|
|
205
|
+
"replacement.skip_confirmed",
|
|
206
|
+
intent_id=str(intent.intent_id),
|
|
207
|
+
attempt_id=str(attempt.attempt_id),
|
|
208
|
+
tx_hash=attempt.tx_hash,
|
|
209
|
+
)
|
|
210
|
+
return ReplacementResult(success=False, error="already_confirmed")
|
|
211
|
+
|
|
212
|
+
current_intent = self._db.get_intent(intent.intent_id)
|
|
213
|
+
if current_intent is None or current_intent.status != IntentStatus.PENDING:
|
|
214
|
+
return ReplacementResult(success=False, error="intent_not_pending")
|
|
215
|
+
|
|
216
|
+
logger.info(
|
|
217
|
+
"replacement.starting",
|
|
218
|
+
intent_id=str(intent.intent_id),
|
|
219
|
+
attempt_id=str(attempt.attempt_id),
|
|
220
|
+
old_tx_hash=attempt.tx_hash,
|
|
221
|
+
nonce=attempt.nonce,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Calculate new gas parameters
|
|
225
|
+
new_gas_params = self.calculate_replacement_fees(attempt.gas_params)
|
|
226
|
+
|
|
227
|
+
# Checksum addresses for RPC/signing
|
|
228
|
+
signer_address = Web3.to_checksum_address(intent.signer_address)
|
|
229
|
+
to_address = Web3.to_checksum_address(intent.to_address)
|
|
230
|
+
|
|
231
|
+
# Build replacement transaction (same nonce!)
|
|
232
|
+
tx_dict = {
|
|
233
|
+
"nonce": attempt.nonce, # SAME nonce as original
|
|
234
|
+
"to": to_address,
|
|
235
|
+
"value": intent.value_wei,
|
|
236
|
+
"gas": new_gas_params.gas_limit,
|
|
237
|
+
"maxFeePerGas": new_gas_params.max_fee_per_gas,
|
|
238
|
+
"maxPriorityFeePerGas": new_gas_params.max_priority_fee_per_gas,
|
|
239
|
+
"chainId": intent.chain_id,
|
|
240
|
+
"type": 2, # EIP-1559
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if intent.data:
|
|
244
|
+
tx_dict["data"] = intent.data
|
|
245
|
+
|
|
246
|
+
tx_dict = normalize_tx_dict(tx_dict)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
# Sign transaction
|
|
250
|
+
signed_tx = self._keystore.sign_transaction(
|
|
251
|
+
tx_dict,
|
|
252
|
+
signer_address,
|
|
253
|
+
)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.error(
|
|
256
|
+
"replacement.sign_failed",
|
|
257
|
+
intent_id=str(intent.intent_id),
|
|
258
|
+
error=str(e)[:200],
|
|
259
|
+
)
|
|
260
|
+
return ReplacementResult(success=False, error=f"Sign failed: {e}")
|
|
261
|
+
|
|
262
|
+
# Create new attempt record
|
|
263
|
+
new_attempt_id = uuid4()
|
|
264
|
+
new_attempt = self._db.create_attempt(
|
|
265
|
+
attempt_id=new_attempt_id,
|
|
266
|
+
intent_id=intent.intent_id,
|
|
267
|
+
nonce=attempt.nonce, # Same nonce
|
|
268
|
+
gas_params_json=new_gas_params.to_json(),
|
|
269
|
+
status=AttemptStatus.SIGNED.value,
|
|
270
|
+
replaces_attempt_id=attempt.attempt_id,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
# Broadcast replacement
|
|
275
|
+
tx_hash, _endpoint_url = self._rpc.send_raw_transaction(signed_tx.raw_transaction)
|
|
276
|
+
|
|
277
|
+
# Update new attempt with tx_hash
|
|
278
|
+
current_block = self._rpc.get_block_number()
|
|
279
|
+
self._db.update_attempt_status(
|
|
280
|
+
new_attempt_id,
|
|
281
|
+
AttemptStatus.BROADCAST.value,
|
|
282
|
+
tx_hash=tx_hash,
|
|
283
|
+
broadcast_block=current_block,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Mark old attempt as replaced
|
|
287
|
+
self._db.update_attempt_status(
|
|
288
|
+
attempt.attempt_id,
|
|
289
|
+
AttemptStatus.REPLACED.value,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
logger.info(
|
|
293
|
+
LogEvents.TX_REPLACED,
|
|
294
|
+
intent_id=str(intent.intent_id),
|
|
295
|
+
old_attempt_id=str(attempt.attempt_id),
|
|
296
|
+
new_attempt_id=str(new_attempt_id),
|
|
297
|
+
old_tx_hash=attempt.tx_hash,
|
|
298
|
+
new_tx_hash=tx_hash,
|
|
299
|
+
nonce=attempt.nonce,
|
|
300
|
+
old_max_fee=attempt.gas_params.max_fee_per_gas,
|
|
301
|
+
new_max_fee=new_gas_params.max_fee_per_gas,
|
|
302
|
+
)
|
|
303
|
+
metrics = get_metrics()
|
|
304
|
+
metrics.counter(TX_REPLACED).inc(
|
|
305
|
+
chain_id=intent.chain_id,
|
|
306
|
+
job_id=intent.job_id,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Refresh attempt from DB
|
|
310
|
+
new_attempt = self._db.get_attempt(new_attempt_id)
|
|
311
|
+
if self._lifecycle and new_attempt is not None:
|
|
312
|
+
self._lifecycle.on_replaced(intent, new_attempt)
|
|
313
|
+
|
|
314
|
+
return ReplacementResult(
|
|
315
|
+
success=True,
|
|
316
|
+
new_attempt=new_attempt,
|
|
317
|
+
new_tx_hash=tx_hash,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
except Exception as e:
|
|
321
|
+
error_str = str(e)
|
|
322
|
+
|
|
323
|
+
# Check for specific errors
|
|
324
|
+
if "replacement transaction underpriced" in error_str.lower():
|
|
325
|
+
logger.warning(
|
|
326
|
+
"replacement.underpriced",
|
|
327
|
+
intent_id=str(intent.intent_id),
|
|
328
|
+
error=error_str[:200],
|
|
329
|
+
)
|
|
330
|
+
# Mark as failed, will retry with higher fees
|
|
331
|
+
self._db.update_attempt_status(
|
|
332
|
+
new_attempt_id,
|
|
333
|
+
AttemptStatus.FAILED.value,
|
|
334
|
+
error_code="replacement_underpriced",
|
|
335
|
+
error_detail=error_str[:500],
|
|
336
|
+
)
|
|
337
|
+
return ReplacementResult(
|
|
338
|
+
success=False,
|
|
339
|
+
error="replacement_underpriced",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
logger.error(
|
|
343
|
+
"replacement.broadcast_failed",
|
|
344
|
+
intent_id=str(intent.intent_id),
|
|
345
|
+
error=error_str[:200],
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
self._db.update_attempt_status(
|
|
349
|
+
new_attempt_id,
|
|
350
|
+
AttemptStatus.FAILED.value,
|
|
351
|
+
error_code="broadcast_failed",
|
|
352
|
+
error_detail=error_str[:500],
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return ReplacementResult(success=False, error=error_str[:200])
|
|
356
|
+
|
|
357
|
+
def abandon_intent(self, intent: TxIntent, attempt: TxAttempt, reason: str) -> None:
|
|
358
|
+
"""Abandon an intent after max replacement attempts.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
intent: Transaction intent
|
|
362
|
+
attempt: Last attempt
|
|
363
|
+
reason: Reason for abandonment
|
|
364
|
+
"""
|
|
365
|
+
# Mark intent as abandoned
|
|
366
|
+
transition_intent(
|
|
367
|
+
self._db,
|
|
368
|
+
intent.intent_id,
|
|
369
|
+
IntentStatus.ABANDONED,
|
|
370
|
+
"max_replacements_exceeded",
|
|
371
|
+
chain_id=self._config.chain_id,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Release nonce reservation (checksum address for nonce manager)
|
|
375
|
+
signer_address = Web3.to_checksum_address(intent.signer_address)
|
|
376
|
+
self._nonce_manager.release(signer_address, attempt.nonce)
|
|
377
|
+
|
|
378
|
+
if self._lifecycle:
|
|
379
|
+
self._lifecycle.on_failed(
|
|
380
|
+
intent,
|
|
381
|
+
attempt,
|
|
382
|
+
RuntimeError(reason),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
logger.warning(
|
|
386
|
+
LogEvents.TX_ABANDONED,
|
|
387
|
+
intent_id=str(intent.intent_id),
|
|
388
|
+
attempt_id=str(attempt.attempt_id),
|
|
389
|
+
nonce=attempt.nonce,
|
|
390
|
+
reason=reason,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def process_stuck_transactions(self) -> dict[str, int]:
|
|
394
|
+
"""Process all stuck transactions and attempt replacement.
|
|
395
|
+
|
|
396
|
+
Single pass through pending intents, checking for stuck transactions
|
|
397
|
+
and attempting replacement where appropriate.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Dict with counts of actions taken
|
|
401
|
+
"""
|
|
402
|
+
results = {
|
|
403
|
+
"checked": 0,
|
|
404
|
+
"replaced": 0,
|
|
405
|
+
"abandoned": 0,
|
|
406
|
+
"errors": 0,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
# Get pending intents
|
|
410
|
+
pending_intents = self._db.get_intents_by_status(
|
|
411
|
+
IntentStatus.PENDING.value,
|
|
412
|
+
chain_id=self._config.chain_id,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
for intent in pending_intents:
|
|
416
|
+
attempt = self._db.get_latest_attempt_for_intent(intent.intent_id)
|
|
417
|
+
if not attempt or not attempt.tx_hash:
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
results["checked"] += 1
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
if self.should_replace(intent, attempt):
|
|
424
|
+
# Check if we've exceeded max replacements
|
|
425
|
+
replacement_count = self.get_replacement_count(intent.intent_id)
|
|
426
|
+
if replacement_count >= self._config.max_replacement_attempts:
|
|
427
|
+
self.abandon_intent(
|
|
428
|
+
intent,
|
|
429
|
+
attempt,
|
|
430
|
+
f"Max replacement attempts ({self._config.max_replacement_attempts}) exceeded",
|
|
431
|
+
)
|
|
432
|
+
results["abandoned"] += 1
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
# Attempt replacement
|
|
436
|
+
result = self.replace_transaction(intent, attempt)
|
|
437
|
+
if result.success:
|
|
438
|
+
results["replaced"] += 1
|
|
439
|
+
else:
|
|
440
|
+
results["errors"] += 1
|
|
441
|
+
|
|
442
|
+
except Exception as e:
|
|
443
|
+
logger.error(
|
|
444
|
+
"replacement.process_failed",
|
|
445
|
+
intent_id=str(intent.intent_id),
|
|
446
|
+
error=str(e)[:200],
|
|
447
|
+
)
|
|
448
|
+
results["errors"] += 1
|
|
449
|
+
|
|
450
|
+
if results["replaced"] > 0 or results["abandoned"] > 0:
|
|
451
|
+
logger.info(
|
|
452
|
+
"replacement.batch_complete",
|
|
453
|
+
**results,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return results
|
brawny/tx/utils.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Transaction utilities."""
|
|
2
|
+
|
|
3
|
+
# Fields that must be integers for Ethereum transactions
|
|
4
|
+
TX_INT_FIELDS = {
|
|
5
|
+
"nonce",
|
|
6
|
+
"gas",
|
|
7
|
+
"gasPrice",
|
|
8
|
+
"maxFeePerGas",
|
|
9
|
+
"maxPriorityFeePerGas",
|
|
10
|
+
"chainId",
|
|
11
|
+
"value",
|
|
12
|
+
"type",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_tx_dict(tx: dict) -> dict:
|
|
17
|
+
"""
|
|
18
|
+
Normalize transaction dict for signing.
|
|
19
|
+
|
|
20
|
+
Converts numeric fields to integers (allows brownie-like float inputs).
|
|
21
|
+
"""
|
|
22
|
+
result = dict(tx)
|
|
23
|
+
for field in TX_INT_FIELDS:
|
|
24
|
+
if field in result and result[field] is not None:
|
|
25
|
+
result[field] = int(result[field])
|
|
26
|
+
return result
|
brawny/utils.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Utility functions for brawny.
|
|
2
|
+
|
|
3
|
+
Provides shared helpers for address normalization, datetime handling, and other
|
|
4
|
+
common operations used across the codebase.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from web3 import Web3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# =============================================================================
|
|
15
|
+
# Address Normalization
|
|
16
|
+
# =============================================================================
|
|
17
|
+
|
|
18
|
+
def normalize_address(address: str) -> str:
|
|
19
|
+
"""Normalize Ethereum address to lowercase for storage.
|
|
20
|
+
|
|
21
|
+
All addresses should be stored lowercase in the database to ensure
|
|
22
|
+
consistent lookups and comparisons.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
address: Ethereum address (any case)
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Lowercase address
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> normalize_address("0xABC123...")
|
|
32
|
+
"0xabc123..."
|
|
33
|
+
"""
|
|
34
|
+
return address.lower()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def checksum_address(address: str) -> str:
|
|
38
|
+
"""Convert address to checksum format for RPC calls.
|
|
39
|
+
|
|
40
|
+
EIP-55 checksum addresses are required by web3.py for RPC calls
|
|
41
|
+
and provide error detection for typos.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
address: Ethereum address (any case)
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Checksummed address
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValueError: If address is invalid
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
>>> checksum_address("0xabc123...")
|
|
54
|
+
"0xABC123..."
|
|
55
|
+
"""
|
|
56
|
+
return Web3.to_checksum_address(address)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def addresses_equal(a: str, b: str) -> bool:
|
|
60
|
+
"""Compare two addresses case-insensitively.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
a: First address
|
|
64
|
+
b: Second address
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True if addresses are equal (ignoring case)
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
>>> addresses_equal("0xABC", "0xabc")
|
|
71
|
+
True
|
|
72
|
+
"""
|
|
73
|
+
return a.lower() == b.lower()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_valid_address(address: str) -> bool:
|
|
77
|
+
"""Check if string is a valid Ethereum address.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
address: String to check
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if valid 40-char hex address with 0x prefix
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
>>> is_valid_address("0x" + "a" * 40)
|
|
87
|
+
True
|
|
88
|
+
>>> is_valid_address("not an address")
|
|
89
|
+
False
|
|
90
|
+
"""
|
|
91
|
+
if not address or not address.startswith("0x"):
|
|
92
|
+
return False
|
|
93
|
+
try:
|
|
94
|
+
# web3 validation
|
|
95
|
+
Web3.to_checksum_address(address)
|
|
96
|
+
return True
|
|
97
|
+
except ValueError:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# =============================================================================
|
|
102
|
+
# Datetime Utilities
|
|
103
|
+
# =============================================================================
|
|
104
|
+
|
|
105
|
+
def utc_now() -> datetime:
|
|
106
|
+
"""Get current UTC time as timezone-aware datetime.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Current time in UTC with tzinfo set
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
>>> utc_now().tzinfo
|
|
113
|
+
datetime.timezone.utc
|
|
114
|
+
"""
|
|
115
|
+
return datetime.now(timezone.utc)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ensure_utc(dt: datetime | None) -> datetime | None:
|
|
119
|
+
"""Ensure datetime is timezone-aware UTC.
|
|
120
|
+
|
|
121
|
+
Handles both naive datetimes (assumed UTC) and timezone-aware datetimes
|
|
122
|
+
(converted to UTC).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
dt: Datetime to normalize, or None
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
UTC datetime with tzinfo, or None if input was None
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
>>> ensure_utc(datetime(2024, 1, 1)) # naive
|
|
132
|
+
datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
|
|
133
|
+
"""
|
|
134
|
+
if dt is None:
|
|
135
|
+
return None
|
|
136
|
+
if dt.tzinfo is None:
|
|
137
|
+
# Assume naive datetimes are UTC
|
|
138
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
139
|
+
return dt.astimezone(timezone.utc)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_expired(deadline: datetime | None) -> bool:
|
|
143
|
+
"""Check if a deadline has passed.
|
|
144
|
+
|
|
145
|
+
Handles None deadlines (never expires), timezone-naive deadlines
|
|
146
|
+
(assumed UTC), and timezone-aware deadlines.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
deadline: Deadline datetime, or None for no deadline
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
True if deadline has passed, False if deadline is None or in future
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
>>> is_expired(None)
|
|
156
|
+
False
|
|
157
|
+
>>> is_expired(datetime(2020, 1, 1))
|
|
158
|
+
True
|
|
159
|
+
"""
|
|
160
|
+
if deadline is None:
|
|
161
|
+
return False
|
|
162
|
+
return utc_now() > ensure_utc(deadline)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def seconds_until(deadline: datetime | None) -> float | None:
|
|
166
|
+
"""Get seconds until a deadline.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
deadline: Deadline datetime, or None
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Seconds until deadline (negative if passed), or None if no deadline
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
>>> seconds_until(utc_now() + timedelta(seconds=60))
|
|
176
|
+
~60.0
|
|
177
|
+
"""
|
|
178
|
+
if deadline is None:
|
|
179
|
+
return None
|
|
180
|
+
deadline_utc = ensure_utc(deadline)
|
|
181
|
+
return (deadline_utc - utc_now()).total_seconds()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# =============================================================================
|
|
185
|
+
# String Utilities
|
|
186
|
+
# =============================================================================
|
|
187
|
+
|
|
188
|
+
def truncate(text: str, max_length: int, suffix: str = "...") -> str:
|
|
189
|
+
"""Truncate text to max length with suffix.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
text: Text to truncate
|
|
193
|
+
max_length: Maximum length including suffix
|
|
194
|
+
suffix: Suffix to append if truncated
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Truncated text with suffix, or original if within limit
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
>>> truncate("hello world", 8)
|
|
201
|
+
"hello..."
|
|
202
|
+
"""
|
|
203
|
+
if len(text) <= max_length:
|
|
204
|
+
return text
|
|
205
|
+
return text[: max_length - len(suffix)] + suffix
|