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/api.py ADDED
@@ -0,0 +1,660 @@
1
+ """Public API helpers for implicit context.
2
+
3
+ These functions provide a Flask-like implicit context pattern for job hooks.
4
+ Import and use them directly - they read from contextvars set by the framework.
5
+
6
+ Usage:
7
+ from brawny import Contract, trigger, intent, block
8
+
9
+ @job(signer="harvester")
10
+ class VaultHarvester(Job):
11
+ def check(self):
12
+ vault = Contract("vault")
13
+ if vault.totalAssets() > 10e18:
14
+ return trigger(reason="Harvest time", tx_required=True)
15
+
16
+ def build_intent(self, trig):
17
+ vault = Contract("vault")
18
+ return intent(
19
+ to_address=vault.address,
20
+ data=vault.harvest.encode_input(),
21
+ ) # signer inherited from @job decorator
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from brawny._context import get_current_job, get_job_context
29
+
30
+ if TYPE_CHECKING:
31
+ from brawny.alerts.contracts import ContractHandle
32
+ from brawny.model.types import Trigger, TxIntentSpec
33
+ from brawny._rpc.gas import GasQuote
34
+
35
+
36
+ def trigger(
37
+ reason: str,
38
+ tx_required: bool = True,
39
+ idempotency_parts: list[str | int | bytes] | None = None,
40
+ ) -> Trigger:
41
+ """Create a Trigger to signal that action is needed.
42
+
43
+ Only valid inside check().
44
+
45
+ Args:
46
+ reason: Human-readable description of why we're triggering
47
+ (auto-included in intent.metadata["reason"])
48
+ tx_required: Whether this trigger needs a transaction (default True)
49
+ idempotency_parts: Optional list for deduplication
50
+
51
+ Returns:
52
+ Trigger instance
53
+
54
+ Raises:
55
+ LookupError: If called outside check()
56
+
57
+ Example:
58
+ if profit > threshold:
59
+ return trigger(reason=f"Harvesting {profit} profit")
60
+ """
61
+ from brawny.model.types import Trigger as TriggerType
62
+
63
+ # Verify we're in a job context (check() or build_tx())
64
+ get_job_context()
65
+
66
+ return TriggerType(
67
+ reason=reason,
68
+ tx_required=tx_required,
69
+ idempotency_parts=idempotency_parts or [],
70
+ )
71
+
72
+
73
+ def intent(
74
+ to_address: str,
75
+ data: str | None = None,
76
+ value_wei: str | int | float = 0,
77
+ gas_limit: int | float | None = None,
78
+ max_fee_per_gas: int | float | None = None,
79
+ max_priority_fee_per_gas: int | float | None = None,
80
+ min_confirmations: int = 1,
81
+ deadline_seconds: int | None = None,
82
+ *,
83
+ signer_address: str | None = None,
84
+ metadata: dict[str, Any] | None = None,
85
+ ) -> TxIntentSpec:
86
+ """Create a transaction intent specification.
87
+
88
+ Only valid inside build_intent().
89
+
90
+ Args:
91
+ to_address: Target contract address
92
+ data: Calldata (hex string)
93
+ value_wei: ETH value in wei (int or string)
94
+ gas_limit: Optional gas limit override
95
+ max_fee_per_gas: Optional EIP-1559 max fee
96
+ max_priority_fee_per_gas: Optional EIP-1559 priority fee
97
+ min_confirmations: Confirmations required (default 1)
98
+ deadline_seconds: Optional deadline for transaction
99
+ signer_address: Signer alias or hex address. If not provided, uses
100
+ the signer from @job(signer="...") decorator.
101
+ metadata: Per-intent context for alerts. Merged with trigger.reason
102
+ (job metadata wins on key collision).
103
+
104
+ Returns:
105
+ TxIntentSpec instance
106
+
107
+ Raises:
108
+ LookupError: If called outside build_intent()
109
+ RuntimeError: If no signer specified and job has no default signer
110
+
111
+ Example:
112
+ vault = Contract("vault")
113
+ return intent(
114
+ to_address=vault.address,
115
+ data=vault.harvest.encode_input(),
116
+ metadata={"profit": str(profit)},
117
+ )
118
+ """
119
+ from brawny.model.types import TxIntentSpec
120
+
121
+ # Verify we're in a job context
122
+ get_job_context()
123
+
124
+ # Resolve signer: explicit param > job decorator > error
125
+ resolved_signer = signer_address
126
+ if resolved_signer is None:
127
+ job = get_current_job()
128
+ if job._signer_name is None:
129
+ raise RuntimeError(
130
+ f"No signer specified. Either pass signer_address= to intent() "
131
+ f"or set @job(signer='...') on {job.job_id}"
132
+ )
133
+ resolved_signer = job._signer_name
134
+
135
+ return TxIntentSpec(
136
+ signer_address=resolved_signer,
137
+ to_address=to_address,
138
+ data=data,
139
+ value_wei=str(int(value_wei)) if isinstance(value_wei, (int, float)) else value_wei,
140
+ gas_limit=int(gas_limit) if gas_limit is not None else None,
141
+ max_fee_per_gas=int(max_fee_per_gas) if max_fee_per_gas is not None else None,
142
+ max_priority_fee_per_gas=int(max_priority_fee_per_gas) if max_priority_fee_per_gas is not None else None,
143
+ min_confirmations=min_confirmations,
144
+ deadline_seconds=deadline_seconds,
145
+ metadata=metadata,
146
+ )
147
+
148
+
149
+ class _BlockProxy:
150
+ """Proxy object for accessing current block info.
151
+
152
+ Provides clean attribute access to block properties without needing
153
+ to pass context explicitly.
154
+
155
+ Usage:
156
+ from brawny import block
157
+
158
+ if block.number % 100 == 0:
159
+ return trigger(reason="Periodic check")
160
+ """
161
+
162
+ @property
163
+ def number(self) -> int:
164
+ """Current block number."""
165
+ return get_job_context().block.number
166
+
167
+ @property
168
+ def timestamp(self) -> int:
169
+ """Current block timestamp (Unix seconds)."""
170
+ return get_job_context().block.timestamp
171
+
172
+ @property
173
+ def hash(self) -> str:
174
+ """Current block hash (hex string with 0x prefix)."""
175
+ return get_job_context().block.hash
176
+
177
+
178
+ # Singleton instance for import
179
+ block = _BlockProxy()
180
+
181
+
182
+ async def gas_ok() -> bool:
183
+ """Check if current gas is acceptable.
184
+
185
+ Gate condition: 2 * base_fee + effective_priority_fee <= effective_max_fee
186
+
187
+ This matches what will actually be submitted, not RPC suggestions.
188
+
189
+ Returns:
190
+ True if gas acceptable or no gating configured (max_fee=None)
191
+
192
+ Example:
193
+ async def check(self):
194
+ if not await gas_ok():
195
+ return None
196
+ """
197
+ from brawny._context import _current_job
198
+ from brawny.logging import get_logger
199
+
200
+ ctx = get_job_context()
201
+ job = _current_job.get()
202
+
203
+ # Get gas settings from job (no config fallback in OE7 contexts)
204
+ effective_max_fee = job.max_fee if job and job.max_fee is not None else None
205
+ effective_priority_fee = job.priority_fee if job and job.priority_fee is not None else 0
206
+
207
+ # No gating if max_fee is None
208
+ if effective_max_fee is None:
209
+ return True
210
+
211
+ # Get quote (async)
212
+ quote = await ctx.rpc.gas_quote()
213
+
214
+ # Compute what we would actually submit
215
+ computed_max_fee = (2 * quote.base_fee) + effective_priority_fee
216
+
217
+ ok = computed_max_fee <= effective_max_fee
218
+
219
+ if not ok:
220
+ logger = get_logger(__name__)
221
+ logger.info(
222
+ "job.skipped_high_gas",
223
+ job_id=job.job_id if job else None,
224
+ base_fee=quote.base_fee,
225
+ effective_priority_fee=effective_priority_fee,
226
+ computed_max_fee=computed_max_fee,
227
+ effective_max_fee=effective_max_fee,
228
+ )
229
+
230
+ return ok
231
+
232
+
233
+ async def gas_quote() -> "GasQuote":
234
+ """Get current gas quote.
235
+
236
+ Example:
237
+ quote = await gas_quote()
238
+ print(f"Base fee: {quote.base_fee / 1e9:.1f} gwei")
239
+ """
240
+ from brawny._rpc.gas import GasQuote
241
+
242
+ return await get_job_context().rpc.gas_quote()
243
+
244
+
245
+ def ctx():
246
+ """Get current phase context.
247
+
248
+ Use when you need the full context object in a job that uses
249
+ the implicit (no-parameter) signature.
250
+
251
+ Example:
252
+ from brawny import ctx, trigger
253
+
254
+ def check(self):
255
+ c = ctx()
256
+ c.logger.info("checking", block=c.block.number)
257
+ return trigger(reason="...")
258
+
259
+ Returns:
260
+ CheckContext (in check()) or BuildContext (in build_tx())
261
+
262
+ Raises:
263
+ LookupError: If called outside of job execution context.
264
+ """
265
+ return get_job_context()
266
+
267
+
268
+ class _KVProxy:
269
+ """Proxy object for accessing job's persistent KV store.
270
+
271
+ Works in job hooks (check/build_intent). Provides get/set/delete for
272
+ persistent key-value storage that survives restarts.
273
+
274
+ Usage:
275
+ from brawny import kv
276
+
277
+ def check(self):
278
+ last_price = kv.get("last_price")
279
+ kv.set("last_price", current_price)
280
+ """
281
+
282
+ def get(self, key: str, default: Any = None) -> Any:
283
+ """Get value from KV store.
284
+
285
+ Args:
286
+ key: Storage key
287
+ default: Default if not found
288
+
289
+ Returns:
290
+ Stored value or default
291
+ """
292
+ return get_job_context().kv.get(key, default)
293
+
294
+ def set(self, key: str, value: Any) -> None:
295
+ """Set value in KV store.
296
+
297
+ Args:
298
+ key: Storage key
299
+ value: JSON-serializable value
300
+ """
301
+ get_job_context().kv.set(key, value)
302
+
303
+ def delete(self, key: str) -> bool:
304
+ """Delete key from KV store.
305
+
306
+ Args:
307
+ key: Storage key
308
+
309
+ Returns:
310
+ True if deleted, False if not found
311
+ """
312
+ return get_job_context().kv.delete(key)
313
+
314
+
315
+ # Singleton instance for import
316
+ kv = _KVProxy()
317
+
318
+
319
+ def _get_rpc_from_context():
320
+ """Get RPC manager from job context or alert context."""
321
+ from brawny._context import _job_ctx, get_alert_context
322
+
323
+ # Try job context first
324
+ job_ctx = _job_ctx.get()
325
+ if job_ctx is not None:
326
+ return job_ctx.rpc
327
+
328
+ # Try alert context (OE7 AlertContext has contracts: ContractFactory)
329
+ alert_ctx = get_alert_context()
330
+ if alert_ctx is not None and alert_ctx.contracts is not None:
331
+ return alert_ctx.contracts._system.rpc
332
+
333
+ raise LookupError(
334
+ "No active context. Must be called from within a job hook "
335
+ "(check/build_intent) or alert hook (alert_*)."
336
+ )
337
+
338
+
339
+ class _RPCProxy:
340
+ """Proxy object for accessing the RPC manager.
341
+
342
+ Works in both job hooks (check/build_intent) and alert hooks.
343
+ Provides access to all RPCManager methods with failover and health tracking.
344
+
345
+ Usage:
346
+ from brawny import rpc
347
+
348
+ def check(self):
349
+ bal = rpc.get_balance(self.operator_address)
350
+ gas = rpc.get_gas_price()
351
+
352
+ def alert_failed(self, ctx):
353
+ bal = rpc.get_balance(self.operator_address) / 1e18
354
+ """
355
+
356
+ def __getattr__(self, name: str):
357
+ """Delegate attribute access to the underlying RPCManager."""
358
+ return getattr(_get_rpc_from_context(), name)
359
+
360
+
361
+ # Singleton instance for import
362
+ rpc = _RPCProxy()
363
+
364
+
365
+ # Thread-safe keystore address resolver - set once at startup
366
+ import threading
367
+
368
+ _keystore = None
369
+ _keystore_lock = threading.Lock()
370
+ _keystore_initialized = threading.Event()
371
+
372
+
373
+ def _set_keystore(ks) -> None:
374
+ """Called by framework at startup to make keystore available.
375
+
376
+ Thread-safe: uses lock and event to ensure visibility across threads.
377
+ Must be called before worker threads are started.
378
+ """
379
+ global _keystore
380
+ with _keystore_lock:
381
+ _keystore = ks
382
+ _keystore_initialized.set()
383
+
384
+
385
+ def _get_keystore():
386
+ """Get keystore with thread-safe access.
387
+
388
+ Returns:
389
+ The keystore instance
390
+
391
+ Raises:
392
+ RuntimeError: If keystore not initialized within timeout
393
+ """
394
+ # Fast path - already initialized
395
+ if _keystore is not None:
396
+ return _keystore
397
+
398
+ # Wait for initialization (with timeout)
399
+ if not _keystore_initialized.wait(timeout=5.0):
400
+ raise RuntimeError(
401
+ "Keystore not initialized within timeout. This is only available "
402
+ "when running with a keystore configured (not in dry-run mode)."
403
+ )
404
+
405
+ with _keystore_lock:
406
+ if _keystore is None:
407
+ raise RuntimeError(
408
+ "Keystore initialization signaled but keystore is None. "
409
+ "This should not happen - please report as a bug."
410
+ )
411
+ return _keystore
412
+
413
+
414
+ def get_address_from_alias(alias: str) -> str:
415
+ """Resolve a signer alias to its address.
416
+
417
+ Thread-safe: properly synchronized access to keystore.
418
+
419
+ Usage:
420
+ from brawny import get_address_from_alias
421
+
422
+ def alert_failed(self, ctx):
423
+ addr = get_address_from_alias("yearn-worker")
424
+ bal = rpc.get_balance(addr) / 1e18
425
+ """
426
+ return _get_keystore().get_address(alias)
427
+
428
+
429
+ def shorten(hex_string: str, prefix: int = 6, suffix: int = 4) -> str:
430
+ """Shorten a hex string (address or hash) for display.
431
+
432
+ Works in any context - no active hook required.
433
+
434
+ Args:
435
+ hex_string: Full hex string (e.g., 0x1234...abcd)
436
+ prefix: Characters to keep at start (including 0x)
437
+ suffix: Characters to keep at end
438
+
439
+ Returns:
440
+ Shortened string like "0x1234...abcd"
441
+
442
+ Example:
443
+ from brawny import shorten
444
+
445
+ def alert_confirmed(self, ctx):
446
+ return f"Tx: {shorten(ctx.receipt.transactionHash.hex())}"
447
+ """
448
+ from brawny.alerts.base import shorten as _shorten
449
+
450
+ return _shorten(hex_string, prefix, suffix)
451
+
452
+
453
+ def explorer_link(
454
+ hash_or_address: str,
455
+ chain_id: int | None = None,
456
+ label: str | None = None,
457
+ ) -> str:
458
+ """Create a Markdown explorer link with emoji.
459
+
460
+ Automatically detects chain_id from alert context if not provided.
461
+ Detects if input is a tx hash (66 chars) or address (42 chars).
462
+
463
+ Args:
464
+ hash_or_address: Transaction hash or address
465
+ chain_id: Chain ID (auto-detected from context if not provided)
466
+ label: Custom label (default: "🔗 View on Explorer")
467
+
468
+ Returns:
469
+ Markdown formatted link like "[🔗 View on Explorer](url)"
470
+
471
+ Example:
472
+ from brawny import explorer_link
473
+
474
+ def alert_confirmed(self, ctx):
475
+ return f"Done!\\n{explorer_link(ctx.receipt.transactionHash.hex())}"
476
+ """
477
+ from brawny._context import get_alert_context
478
+ from brawny.alerts.base import explorer_link as _explorer_link
479
+
480
+ if chain_id is None:
481
+ alert_ctx = get_alert_context()
482
+ if alert_ctx is not None:
483
+ chain_id = alert_ctx.chain_id
484
+ else:
485
+ chain_id = 1 # Default to mainnet
486
+
487
+ return _explorer_link(hash_or_address, chain_id, label)
488
+
489
+
490
+ def Contract(address: str, abi: list[dict[str, Any]] | None = None) -> ContractHandle:
491
+ """Get a contract handle (Brownie-style).
492
+
493
+ Works in job hooks (check/build_intent) and alert hooks.
494
+
495
+ Args:
496
+ address: Ethereum address (0x...)
497
+ abi: Optional ABI override
498
+
499
+ Returns:
500
+ ContractHandle bound to current context's RPC
501
+
502
+ Raises:
503
+ LookupError: If called outside a job or alert hook
504
+
505
+ Example:
506
+ vault = Contract(self.vault_address)
507
+ decimals = vault.decimals()
508
+ """
509
+ from brawny._context import _job_ctx, get_alert_context, get_console_context
510
+
511
+ # Try job context first (CheckContext or BuildContext)
512
+ job_ctx = _job_ctx.get()
513
+ if job_ctx is not None:
514
+ if job_ctx.contracts is None:
515
+ raise RuntimeError(
516
+ "Contract system not configured. Ensure the contract system is initialized."
517
+ )
518
+ # Access the underlying ContractSystem via SimpleContractFactory._system
519
+ return job_ctx.contracts._system.handle(
520
+ address=address,
521
+ job_id=job_ctx.job_id,
522
+ abi=abi,
523
+ )
524
+
525
+ # Try alert context (new OE7 AlertContext from model/contexts.py)
526
+ alert_ctx = get_alert_context()
527
+ if alert_ctx is not None:
528
+ if alert_ctx.contracts is None:
529
+ raise RuntimeError(
530
+ "Contract system not configured. Ensure the contract system is initialized."
531
+ )
532
+ # Access the underlying ContractSystem via SimpleContractFactory._system
533
+ return alert_ctx.contracts._system.handle(
534
+ address=address,
535
+ abi=abi,
536
+ )
537
+
538
+ # Try console context
539
+ console_ctx = get_console_context()
540
+ if console_ctx is not None:
541
+ return console_ctx.contract_system.handle(address=address, abi=abi)
542
+
543
+ raise LookupError(
544
+ "No active context. Contract() must be called from within a job hook "
545
+ "(check/build_intent), alert hook (alert_*), or console."
546
+ )
547
+
548
+
549
+ def Wei(value: str | int) -> int:
550
+ """Convert to wei. Brownie-compatible.
551
+
552
+ Works anywhere - no active context required.
553
+
554
+ Args:
555
+ value: Integer wei amount, or string like "1 ether", "100 gwei"
556
+
557
+ Returns:
558
+ Amount in wei (int)
559
+
560
+ Example:
561
+ Wei("1 ether") → 1000000000000000000
562
+ Wei("100 gwei") → 100000000000
563
+ Wei("500 wei") → 500
564
+ Wei(123) → 123
565
+ """
566
+ if isinstance(value, int):
567
+ return value
568
+ value_str = str(value).strip().lower()
569
+ if value_str.endswith(" ether"):
570
+ return int(float(value_str[:-6]) * 10**18)
571
+ elif value_str.endswith(" gwei"):
572
+ return int(float(value_str[:-5]) * 10**9)
573
+ elif value_str.endswith(" wei"):
574
+ return int(value_str[:-4])
575
+ return int(value_str)
576
+
577
+
578
+ class _Web3Proxy:
579
+ """Proxy for context-aware web3 access.
580
+
581
+ Provides full web3-py API while using RPCManager's health-tracked endpoints.
582
+ Works in job hooks (check/build_intent) and alert hooks.
583
+
584
+ Usage:
585
+ from brawny import web3
586
+
587
+ balance = web3.eth.get_balance("0x...")
588
+ block = web3.eth.get_block("latest")
589
+ chain_id = web3.eth.chain_id
590
+ """
591
+
592
+ def __getattr__(self, name: str) -> Any:
593
+ """Delegate attribute access to the underlying Web3 instance."""
594
+ return getattr(self._get_web3(), name)
595
+
596
+ def _get_web3(self):
597
+ """Get Web3 instance from active context."""
598
+ from brawny._context import _job_ctx, get_alert_context, get_console_context
599
+
600
+ # Try job context first
601
+ job_ctx = _job_ctx.get()
602
+ if job_ctx is not None and job_ctx.rpc is not None:
603
+ return job_ctx.rpc.web3
604
+
605
+ # Try alert context (OE7 AlertContext has contracts: ContractFactory)
606
+ alert_ctx = get_alert_context()
607
+ if alert_ctx is not None and alert_ctx.contracts is not None:
608
+ return alert_ctx.contracts._system.rpc.web3
609
+
610
+ # Try console context
611
+ console_ctx = get_console_context()
612
+ if console_ctx is not None:
613
+ return console_ctx.rpc.web3
614
+
615
+ raise LookupError(
616
+ "No active context. web3 must be used from within a job hook "
617
+ "(check/build_intent), alert hook (alert_*), or console."
618
+ )
619
+
620
+ def __repr__(self) -> str:
621
+ try:
622
+ w3 = self._get_web3()
623
+ return f"<Web3Proxy connected to chain {w3.eth.chain_id}>"
624
+ except LookupError:
625
+ return "<Web3Proxy (no active context)>"
626
+
627
+
628
+ # Singleton instance for import
629
+ web3 = _Web3Proxy()
630
+
631
+
632
+ # Re-export Brownie-style singletons for convenience
633
+ from brawny.accounts import accounts, Account
634
+ from brawny.history import history
635
+ from brawny.chain import chain
636
+
637
+ # Re-export alert function for use in hooks
638
+ from brawny.alerts.send import alert
639
+
640
+ __all__ = [
641
+ "trigger",
642
+ "intent",
643
+ "alert",
644
+ "block",
645
+ "ctx",
646
+ "gas_ok",
647
+ "gas_quote",
648
+ "kv",
649
+ "rpc",
650
+ "Contract",
651
+ "Wei",
652
+ "web3",
653
+ "shorten",
654
+ "explorer_link",
655
+ "get_address_from_alias",
656
+ "accounts",
657
+ "Account",
658
+ "history",
659
+ "chain",
660
+ ]