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.
Files changed (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. brawny-0.1.13.dist-info/top_level.txt +1 -0
@@ -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