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/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 []
|