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
brawny/lifecycle.py ADDED
@@ -0,0 +1,551 @@
1
+ """Simplified lifecycle dispatcher for job hooks.
2
+
3
+ Implements 3 lifecycle hooks (on_trigger, on_success, on_failure).
4
+ Jobs call alert() explicitly within hooks to send notifications.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import contextmanager
10
+ from typing import TYPE_CHECKING, Any
11
+ from uuid import UUID
12
+
13
+ from brawny.alerts.send import AlertConfig, AlertEvent, AlertPayload
14
+ from brawny.jobs.kv import DatabaseJobKVStore, DatabaseJobKVReader
15
+ from brawny.logging import LogEvents, get_logger
16
+ from brawny.model.contexts import (
17
+ AlertContext,
18
+ BlockContext,
19
+ TriggerContext,
20
+ SuccessContext,
21
+ FailureContext,
22
+ )
23
+ from brawny.model.errors import (
24
+ ErrorInfo,
25
+ FailureStage,
26
+ FailureType,
27
+ HookType,
28
+ TriggerReason,
29
+ )
30
+ from brawny.model.events import DecodedEvent
31
+ from brawny.model.types import BlockInfo, Trigger, HookName
32
+
33
+ if TYPE_CHECKING:
34
+ from brawny.config import Config
35
+ from brawny.db.base import Database
36
+ from brawny.jobs.base import Job, TxInfo, TxReceipt, BlockInfo as AlertBlockInfo
37
+ from brawny.model.types import TxAttempt, TxIntent
38
+ from brawny._rpc.manager import RPCManager
39
+ from brawny.alerts.contracts import ContractSystem, SimpleContractFactory
40
+ from brawny.telegram import TelegramBot
41
+
42
+ logger = get_logger(__name__)
43
+
44
+
45
+ class LifecycleDispatcher:
46
+ """Dispatch job lifecycle hooks.
47
+
48
+ Lifecycle Hooks (3):
49
+ - on_trigger: Job check returns Trigger, BEFORE build_tx
50
+ - on_success: Transaction confirmed on-chain
51
+ - on_failure: Any failure (intent may be None for pre-intent failures)
52
+
53
+ Jobs call alert() explicitly within hooks to send notifications.
54
+ All hook invocations go through dispatch_hook() for consistent context setup.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ db: Database,
60
+ rpc: RPCManager,
61
+ config: Config,
62
+ jobs: dict[str, Job],
63
+ contract_system: ContractSystem | None = None,
64
+ telegram_bot: "TelegramBot | None" = None,
65
+ ) -> None:
66
+ self._db = db
67
+ self._rpc = rpc
68
+ self._config = config
69
+ self._jobs = jobs
70
+ self._contract_system = contract_system
71
+ self._telegram_bot = telegram_bot
72
+ self._global_alert_config = self._build_global_alert_config()
73
+
74
+ # =========================================================================
75
+ # Hook Dispatch (Single Entry Point)
76
+ # =========================================================================
77
+
78
+ def dispatch_hook(self, job: Job, hook: HookName, ctx: Any) -> None:
79
+ """Dispatch a lifecycle hook with proper alert context setup.
80
+
81
+ All hook invocations must go through this method to ensure
82
+ alert() works correctly within hooks.
83
+
84
+ Args:
85
+ job: The job instance
86
+ hook: Hook name ("on_trigger", "on_success", "on_failure")
87
+ ctx: The context to pass to the hook (TriggerContext, SuccessContext, FailureContext)
88
+ """
89
+ from brawny.scripting import set_job_context
90
+
91
+ hook_fn = getattr(job, hook, None)
92
+ if hook_fn is None:
93
+ return
94
+
95
+ try:
96
+ with self._alert_context(ctx):
97
+ set_job_context(True)
98
+ hook_fn(ctx)
99
+ except Exception as e:
100
+ logger.error(
101
+ f"job.{hook}_crashed",
102
+ job_id=job.job_id,
103
+ error=str(e)[:200],
104
+ )
105
+ if self._has_alert_config():
106
+ self._send_hook_error_alert(job.job_id, hook, e)
107
+ finally:
108
+ set_job_context(False)
109
+
110
+ @contextmanager
111
+ def _alert_context(self, ctx: Any):
112
+ """Set alert context for duration of hook execution with token-based reset."""
113
+ from brawny._context import set_alert_context, reset_alert_context
114
+
115
+ token = set_alert_context(ctx)
116
+ try:
117
+ yield
118
+ finally:
119
+ reset_alert_context(token)
120
+
121
+ # =========================================================================
122
+ # Public API
123
+ # =========================================================================
124
+
125
+ def on_triggered(
126
+ self,
127
+ job: Job,
128
+ trigger: Trigger,
129
+ block: BlockInfo,
130
+ intent_id: UUID | None = None,
131
+ ) -> None:
132
+ """Called when job check returns a Trigger. Runs BEFORE build_tx."""
133
+ # Build TriggerContext
134
+ block_ctx = BlockContext(
135
+ number=block.block_number,
136
+ timestamp=block.timestamp,
137
+ hash=block.block_hash,
138
+ base_fee=0,
139
+ chain_id=block.chain_id,
140
+ )
141
+ ctx = TriggerContext(
142
+ trigger=trigger,
143
+ block=block_ctx,
144
+ kv=DatabaseJobKVStore(self._db, job.job_id),
145
+ logger=logger.bind(job_id=job.job_id, chain_id=self._config.chain_id),
146
+ job_id=job.job_id,
147
+ job_name=job.name,
148
+ chain_id=self._config.chain_id,
149
+ alert_config=self._get_alert_config_for_job(job),
150
+ telegram_config=self._config.telegram,
151
+ telegram_bot=self._telegram_bot,
152
+ job_alert_to=getattr(job, "_alert_to", None),
153
+ )
154
+ self.dispatch_hook(job, "on_trigger", ctx)
155
+
156
+ def on_submitted(self, intent: TxIntent, attempt: TxAttempt) -> None:
157
+ """Log submission for observability. No job hook."""
158
+ logger.info(
159
+ "tx.submitted",
160
+ intent_id=str(intent.intent_id),
161
+ attempt_id=str(attempt.attempt_id),
162
+ tx_hash=attempt.tx_hash,
163
+ nonce=attempt.nonce,
164
+ job_id=intent.job_id,
165
+ chain_id=self._config.chain_id,
166
+ )
167
+
168
+ def on_confirmed(
169
+ self,
170
+ intent: TxIntent,
171
+ attempt: TxAttempt,
172
+ receipt: dict[str, Any],
173
+ ) -> None:
174
+ """Called when transaction is confirmed on-chain."""
175
+ job = self._jobs.get(intent.job_id)
176
+ if not job:
177
+ return
178
+
179
+ # Build SuccessContext
180
+ alert_receipt = self._build_alert_receipt(receipt)
181
+ block_ctx = self._to_block_context(self._fetch_block(receipt.get("blockNumber")))
182
+ events = self._decode_receipt_events(alert_receipt) if self._contract_system else None
183
+
184
+ ctx = SuccessContext(
185
+ intent=intent,
186
+ receipt=alert_receipt,
187
+ events=events,
188
+ block=block_ctx,
189
+ kv=DatabaseJobKVReader(self._db, job.job_id),
190
+ logger=logger.bind(job_id=job.job_id, chain_id=self._config.chain_id),
191
+ job_id=job.job_id,
192
+ job_name=job.name,
193
+ chain_id=self._config.chain_id,
194
+ alert_config=self._get_alert_config_for_job(job),
195
+ telegram_config=self._config.telegram,
196
+ telegram_bot=self._telegram_bot,
197
+ job_alert_to=getattr(job, "_alert_to", None),
198
+ )
199
+ self.dispatch_hook(job, "on_success", ctx)
200
+
201
+ def on_failed(
202
+ self,
203
+ intent: TxIntent,
204
+ attempt: TxAttempt | None,
205
+ error: Exception,
206
+ failure_type: FailureType,
207
+ failure_stage: FailureStage | None = None,
208
+ ) -> None:
209
+ """Called on any terminal failure with intent. Error is required."""
210
+ job = self._jobs.get(intent.job_id)
211
+ if not job:
212
+ return
213
+
214
+ # Build FailureContext
215
+ block_ctx = self._to_block_context(self._get_block_for_failed(attempt, None))
216
+
217
+ ctx = FailureContext(
218
+ intent=intent,
219
+ attempt=attempt,
220
+ error=error,
221
+ failure_type=failure_type,
222
+ failure_stage=failure_stage,
223
+ block=block_ctx,
224
+ kv=DatabaseJobKVReader(self._db, job.job_id),
225
+ logger=logger.bind(job_id=job.job_id, chain_id=self._config.chain_id),
226
+ job_id=job.job_id,
227
+ job_name=job.name,
228
+ chain_id=self._config.chain_id,
229
+ alert_config=self._get_alert_config_for_job(job),
230
+ telegram_config=self._config.telegram,
231
+ telegram_bot=self._telegram_bot,
232
+ job_alert_to=getattr(job, "_alert_to", None),
233
+ )
234
+ self.dispatch_hook(job, "on_failure", ctx)
235
+
236
+ def on_check_failed(
237
+ self,
238
+ job: Job,
239
+ error: Exception,
240
+ block: BlockInfo,
241
+ ) -> None:
242
+ """Called when job.check() raises an exception. No intent exists."""
243
+ block_ctx = BlockContext(
244
+ number=block.block_number,
245
+ timestamp=block.timestamp,
246
+ hash=block.block_hash,
247
+ base_fee=0,
248
+ chain_id=block.chain_id,
249
+ )
250
+ ctx = FailureContext(
251
+ intent=None,
252
+ attempt=None,
253
+ error=error,
254
+ failure_type=FailureType.CHECK_EXCEPTION,
255
+ failure_stage=None,
256
+ block=block_ctx,
257
+ kv=DatabaseJobKVReader(self._db, job.job_id),
258
+ logger=logger.bind(job_id=job.job_id, chain_id=self._config.chain_id),
259
+ job_id=job.job_id,
260
+ job_name=job.name,
261
+ chain_id=self._config.chain_id,
262
+ alert_config=self._get_alert_config_for_job(job),
263
+ telegram_config=self._config.telegram,
264
+ telegram_bot=self._telegram_bot,
265
+ job_alert_to=getattr(job, "_alert_to", None),
266
+ )
267
+ self.dispatch_hook(job, "on_failure", ctx)
268
+
269
+ def on_build_tx_failed(
270
+ self,
271
+ job: Job,
272
+ trigger: Trigger,
273
+ error: Exception,
274
+ block: BlockInfo,
275
+ ) -> None:
276
+ """Called when job.build_tx() raises an exception. No intent exists."""
277
+ block_ctx = BlockContext(
278
+ number=block.block_number,
279
+ timestamp=block.timestamp,
280
+ hash=block.block_hash,
281
+ base_fee=0,
282
+ chain_id=block.chain_id,
283
+ )
284
+ ctx = FailureContext(
285
+ intent=None,
286
+ attempt=None,
287
+ error=error,
288
+ failure_type=FailureType.BUILD_TX_EXCEPTION,
289
+ failure_stage=None,
290
+ block=block_ctx,
291
+ kv=DatabaseJobKVReader(self._db, job.job_id),
292
+ logger=logger.bind(job_id=job.job_id, chain_id=self._config.chain_id),
293
+ job_id=job.job_id,
294
+ job_name=job.name,
295
+ chain_id=self._config.chain_id,
296
+ alert_config=self._get_alert_config_for_job(job),
297
+ telegram_config=self._config.telegram,
298
+ telegram_bot=self._telegram_bot,
299
+ job_alert_to=getattr(job, "_alert_to", None),
300
+ )
301
+ self.dispatch_hook(job, "on_failure", ctx)
302
+
303
+ def on_deep_reorg(
304
+ self, oldest_known: int | None, history_size: int, last_processed: int
305
+ ) -> None:
306
+ """System-level alert for deep reorg. Not job-specific."""
307
+ if not self._has_alert_config():
308
+ return
309
+ message = (
310
+ f"Deep reorg detected. History window is insufficient "
311
+ f"to safely verify the chain.\n"
312
+ f"oldest_known={oldest_known}, history_size={history_size}, "
313
+ f"last_processed={last_processed}"
314
+ )
315
+ payload = AlertPayload(
316
+ job_id="system",
317
+ job_name="Deep Reorg",
318
+ event_type=AlertEvent.FAILED,
319
+ message=message,
320
+ parse_mode=self._default_parse_mode(),
321
+ chain_id=self._config.chain_id,
322
+ )
323
+ self._fire_alert(payload, self._global_alert_config)
324
+
325
+
326
+ def _send_hook_error_alert(self, job_id: str, hook_type: str, error: Exception) -> None:
327
+ """Send fallback error alert when a hook fails."""
328
+ message = f"Alert hook failed for job {job_id}: {error}"
329
+ payload = AlertPayload(
330
+ job_id=job_id,
331
+ job_name=job_id,
332
+ event_type=AlertEvent.FAILED,
333
+ message=message,
334
+ parse_mode=self._default_parse_mode(),
335
+ chain_id=self._config.chain_id,
336
+ )
337
+ self._fire_alert(payload, self._global_alert_config)
338
+
339
+ def _default_parse_mode(self) -> str:
340
+ """Get default parse mode for alerts."""
341
+ return self._config.telegram.parse_mode or "Markdown"
342
+
343
+ def _fire_alert(self, payload: AlertPayload, config: AlertConfig) -> None:
344
+ """Fire alert asynchronously. Fire-and-forget."""
345
+ import asyncio
346
+ from brawny.alerts import send as alerts_send
347
+
348
+ try:
349
+ loop = asyncio.get_running_loop()
350
+ loop.create_task(alerts_send.send_alert(payload, config))
351
+ except RuntimeError:
352
+ # No running loop - run synchronously
353
+ asyncio.run(alerts_send.send_alert(payload, config))
354
+
355
+ def _build_global_alert_config(self) -> AlertConfig:
356
+ """Build global AlertConfig from application config (legacy compatibility)."""
357
+ # Use new telegram config structure
358
+ tg = self._config.telegram
359
+ chat_ids: list[str] = []
360
+
361
+ # Resolve default targets to chat IDs
362
+ if tg.default:
363
+ for name in tg.default:
364
+ if name in tg.chats:
365
+ chat_ids.append(tg.chats[name])
366
+ elif name.lstrip("-").isdigit():
367
+ # Raw chat ID
368
+ chat_ids.append(name)
369
+
370
+ return AlertConfig(
371
+ telegram_token=tg.bot_token,
372
+ telegram_chat_ids=chat_ids,
373
+ )
374
+
375
+ def _get_alert_config_for_job(self, job: Job) -> AlertConfig:
376
+ """Resolve per-job overrides into job-scoped AlertConfig (legacy compatibility)."""
377
+ job_chat_ids = getattr(job, "telegram_chat_ids", None)
378
+ if job_chat_ids:
379
+ # Job-level targets override global (legacy API)
380
+ return AlertConfig(
381
+ telegram_token=self._config.telegram.bot_token,
382
+ telegram_chat_ids=list(job_chat_ids),
383
+ )
384
+ return self._global_alert_config
385
+
386
+ def _has_alert_config(self) -> bool:
387
+ """Check if any alert transport is configured."""
388
+ return bool(
389
+ self._global_alert_config.telegram_token
390
+ and self._global_alert_config.telegram_chat_ids
391
+ )
392
+
393
+ # =========================================================================
394
+ # Helpers
395
+ # =========================================================================
396
+
397
+ def _build_tx_info(
398
+ self, intent: TxIntent | None, attempt: TxAttempt | None
399
+ ) -> TxInfo | None:
400
+ """Build TxInfo from intent, enrich with attempt if available."""
401
+ if intent is None:
402
+ return None
403
+
404
+ from brawny.jobs.base import TxInfo
405
+
406
+ # Safe access for optional gas_params
407
+ gp = getattr(attempt, "gas_params", None) if attempt else None
408
+
409
+ return TxInfo(
410
+ hash=attempt.tx_hash if attempt else None,
411
+ nonce=attempt.nonce if attempt else None,
412
+ from_address=intent.signer_address,
413
+ to_address=intent.to_address,
414
+ gas_limit=gp.gas_limit if gp else getattr(intent, "gas_limit", 0),
415
+ max_fee_per_gas=gp.max_fee_per_gas if gp else getattr(intent, "max_fee_per_gas", 0),
416
+ max_priority_fee_per_gas=gp.max_priority_fee_per_gas if gp else getattr(intent, "max_priority_fee_per_gas", 0),
417
+ )
418
+
419
+ def _build_alert_receipt(self, receipt: dict[str, Any]) -> TxReceipt:
420
+ """Convert raw receipt dict to TxReceipt."""
421
+ from brawny.jobs.base import TxReceipt
422
+
423
+ tx_hash = receipt.get("transactionHash")
424
+ if hasattr(tx_hash, "hex"):
425
+ tx_hash = f"0x{tx_hash.hex()}"
426
+ block_hash = receipt.get("blockHash")
427
+ if hasattr(block_hash, "hex"):
428
+ block_hash = f"0x{block_hash.hex()}"
429
+ return TxReceipt(
430
+ transaction_hash=tx_hash,
431
+ block_number=receipt.get("blockNumber"),
432
+ block_hash=block_hash,
433
+ status=receipt.get("status", 1),
434
+ gas_used=receipt.get("gasUsed", 0),
435
+ logs=receipt.get("logs", []),
436
+ )
437
+
438
+ def _get_block_for_failed(
439
+ self,
440
+ attempt: TxAttempt | None,
441
+ receipt: dict[str, Any] | None,
442
+ ) -> AlertBlockInfo | None:
443
+ """Determine block for failed alert. Explicit priority."""
444
+ if receipt and "blockNumber" in receipt:
445
+ return self._fetch_block(receipt["blockNumber"])
446
+ if attempt and attempt.broadcast_block:
447
+ return self._fetch_block(attempt.broadcast_block)
448
+ return None
449
+
450
+ def _fetch_block(self, block_number: int | None) -> AlertBlockInfo | None:
451
+ """Fetch block info by number."""
452
+ if block_number is None:
453
+ return None
454
+ try:
455
+ block = self._rpc.get_block(block_number)
456
+ except Exception:
457
+ return None
458
+ return self._to_alert_block(
459
+ BlockInfo(
460
+ chain_id=self._config.chain_id,
461
+ block_number=block["number"],
462
+ block_hash=f"0x{block['hash'].hex()}"
463
+ if hasattr(block["hash"], "hex")
464
+ else block["hash"],
465
+ timestamp=block["timestamp"],
466
+ )
467
+ )
468
+
469
+ def _model_block_from_number(self, block_number: int) -> BlockInfo | None:
470
+ """Get BlockInfo model from block number."""
471
+ try:
472
+ block = self._rpc.get_block(block_number)
473
+ except Exception:
474
+ return None
475
+ return BlockInfo(
476
+ chain_id=self._config.chain_id,
477
+ block_number=block["number"],
478
+ block_hash=f"0x{block['hash'].hex()}"
479
+ if hasattr(block["hash"], "hex")
480
+ else block["hash"],
481
+ timestamp=block["timestamp"],
482
+ )
483
+
484
+ def _to_alert_block(self, block: BlockInfo) -> AlertBlockInfo:
485
+ """Convert model BlockInfo to alert BlockInfo."""
486
+ from brawny.jobs.base import BlockInfo as AlertBlockInfo
487
+
488
+ return AlertBlockInfo(
489
+ number=block.block_number,
490
+ hash=block.block_hash,
491
+ timestamp=block.timestamp,
492
+ )
493
+
494
+ def _to_block_context(self, alert_block: AlertBlockInfo | None) -> BlockContext:
495
+ """Convert alert BlockInfo to BlockContext."""
496
+ if alert_block is None:
497
+ # Default block context when no block available
498
+ return BlockContext(
499
+ number=0,
500
+ timestamp=0,
501
+ hash="0x0",
502
+ base_fee=0,
503
+ chain_id=self._config.chain_id,
504
+ )
505
+ return BlockContext(
506
+ number=alert_block.number,
507
+ timestamp=alert_block.timestamp,
508
+ hash=alert_block.hash,
509
+ base_fee=0, # Not always available in alert context
510
+ chain_id=self._config.chain_id,
511
+ )
512
+
513
+ def _decode_receipt_events(self, receipt: TxReceipt) -> list[DecodedEvent]:
514
+ """Decode events from receipt using contract system."""
515
+ if self._contract_system is None:
516
+ return []
517
+
518
+ try:
519
+ from brawny.alerts.events import decode_logs
520
+
521
+ event_dict = decode_logs(
522
+ logs=receipt.logs,
523
+ contract_system=self._contract_system,
524
+ )
525
+ # Convert EventDict to list[DecodedEvent]
526
+ events: list[DecodedEvent] = []
527
+ for event_name, event_item in event_dict.items():
528
+ # event_item is _EventItem
529
+ # Use getattr with fallbacks for robustness
530
+ args_list = getattr(event_item, "_events", None) or []
531
+ addr_list = getattr(event_item, "_addresses", None) or []
532
+ pos_list = getattr(event_item, "pos", None) or []
533
+
534
+ for i, args in enumerate(args_list):
535
+ address = addr_list[i] if i < len(addr_list) else ""
536
+ log_index = pos_list[i] if i < len(pos_list) else 0
537
+
538
+ events.append(
539
+ DecodedEvent.create(
540
+ address=address,
541
+ event_name=event_name,
542
+ args=args,
543
+ log_index=log_index,
544
+ tx_hash=receipt.transactionHash,
545
+ block_number=receipt.blockNumber,
546
+ )
547
+ )
548
+ return events
549
+ except Exception as e:
550
+ logger.warning("events.decode_failed", error=str(e)[:200])
551
+ return []