brawny 0.1.13__py3-none-any.whl → 0.1.22__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 (135) hide show
  1. brawny/__init__.py +2 -0
  2. brawny/_context.py +5 -5
  3. brawny/_rpc/__init__.py +36 -12
  4. brawny/_rpc/broadcast.py +14 -13
  5. brawny/_rpc/caller.py +243 -0
  6. brawny/_rpc/client.py +539 -0
  7. brawny/_rpc/clients.py +11 -11
  8. brawny/_rpc/context.py +23 -0
  9. brawny/_rpc/errors.py +465 -31
  10. brawny/_rpc/gas.py +7 -6
  11. brawny/_rpc/pool.py +18 -0
  12. brawny/_rpc/retry.py +266 -0
  13. brawny/_rpc/retry_policy.py +81 -0
  14. brawny/accounts.py +28 -9
  15. brawny/alerts/__init__.py +15 -18
  16. brawny/alerts/abi_resolver.py +212 -36
  17. brawny/alerts/base.py +2 -2
  18. brawny/alerts/contracts.py +77 -10
  19. brawny/alerts/errors.py +30 -3
  20. brawny/alerts/events.py +38 -5
  21. brawny/alerts/health.py +19 -13
  22. brawny/alerts/send.py +513 -55
  23. brawny/api.py +39 -11
  24. brawny/assets/AGENTS.md +325 -0
  25. brawny/async_runtime.py +48 -0
  26. brawny/chain.py +3 -3
  27. brawny/cli/commands/__init__.py +2 -0
  28. brawny/cli/commands/console.py +69 -19
  29. brawny/cli/commands/contract.py +2 -2
  30. brawny/cli/commands/controls.py +121 -0
  31. brawny/cli/commands/health.py +2 -2
  32. brawny/cli/commands/job_dev.py +6 -5
  33. brawny/cli/commands/jobs.py +99 -2
  34. brawny/cli/commands/maintenance.py +13 -29
  35. brawny/cli/commands/migrate.py +1 -0
  36. brawny/cli/commands/run.py +10 -3
  37. brawny/cli/commands/script.py +8 -3
  38. brawny/cli/commands/signer.py +143 -26
  39. brawny/cli/helpers.py +0 -3
  40. brawny/cli_templates.py +25 -349
  41. brawny/config/__init__.py +4 -1
  42. brawny/config/models.py +43 -57
  43. brawny/config/parser.py +268 -57
  44. brawny/config/validation.py +52 -15
  45. brawny/daemon/context.py +4 -2
  46. brawny/daemon/core.py +185 -63
  47. brawny/daemon/loops.py +166 -98
  48. brawny/daemon/supervisor.py +261 -0
  49. brawny/db/__init__.py +14 -26
  50. brawny/db/base.py +248 -151
  51. brawny/db/global_cache.py +11 -1
  52. brawny/db/migrate.py +175 -28
  53. brawny/db/migrations/001_init.sql +4 -3
  54. brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
  55. brawny/db/migrations/011_add_job_logs.sql +1 -2
  56. brawny/db/migrations/012_add_claimed_by.sql +2 -2
  57. brawny/db/migrations/013_attempt_unique.sql +10 -0
  58. brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
  59. brawny/db/migrations/015_add_signer_alias.sql +14 -0
  60. brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
  61. brawny/db/migrations/017_add_job_drain.sql +6 -0
  62. brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
  63. brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
  64. brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
  65. brawny/db/ops/__init__.py +3 -25
  66. brawny/db/ops/logs.py +1 -2
  67. brawny/db/queries.py +47 -91
  68. brawny/db/serialized.py +65 -0
  69. brawny/db/sqlite/__init__.py +1001 -0
  70. brawny/db/sqlite/connection.py +231 -0
  71. brawny/db/sqlite/execute.py +116 -0
  72. brawny/db/sqlite/mappers.py +190 -0
  73. brawny/db/sqlite/repos/attempts.py +372 -0
  74. brawny/db/sqlite/repos/block_state.py +102 -0
  75. brawny/db/sqlite/repos/cache.py +104 -0
  76. brawny/db/sqlite/repos/intents.py +1021 -0
  77. brawny/db/sqlite/repos/jobs.py +200 -0
  78. brawny/db/sqlite/repos/maintenance.py +182 -0
  79. brawny/db/sqlite/repos/signers_nonces.py +566 -0
  80. brawny/db/sqlite/tx.py +119 -0
  81. brawny/http.py +194 -0
  82. brawny/invariants.py +11 -24
  83. brawny/jobs/base.py +8 -0
  84. brawny/jobs/job_validation.py +2 -1
  85. brawny/keystore.py +83 -7
  86. brawny/lifecycle.py +64 -12
  87. brawny/logging.py +0 -2
  88. brawny/metrics.py +84 -12
  89. brawny/model/contexts.py +111 -9
  90. brawny/model/enums.py +1 -0
  91. brawny/model/errors.py +18 -0
  92. brawny/model/types.py +47 -131
  93. brawny/network_guard.py +133 -0
  94. brawny/networks/__init__.py +5 -5
  95. brawny/networks/config.py +1 -7
  96. brawny/networks/manager.py +14 -11
  97. brawny/runtime_controls.py +74 -0
  98. brawny/scheduler/poller.py +11 -7
  99. brawny/scheduler/reorg.py +95 -39
  100. brawny/scheduler/runner.py +442 -168
  101. brawny/scheduler/shutdown.py +3 -3
  102. brawny/script_tx.py +3 -3
  103. brawny/telegram.py +53 -7
  104. brawny/testing.py +1 -0
  105. brawny/timeout.py +38 -0
  106. brawny/tx/executor.py +922 -308
  107. brawny/tx/intent.py +54 -16
  108. brawny/tx/monitor.py +31 -12
  109. brawny/tx/nonce.py +212 -90
  110. brawny/tx/replacement.py +69 -18
  111. brawny/tx/retry_policy.py +24 -0
  112. brawny/tx/stages/types.py +75 -0
  113. brawny/types.py +18 -0
  114. brawny/utils.py +41 -0
  115. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
  116. brawny-0.1.22.dist-info/RECORD +163 -0
  117. brawny/_rpc/manager.py +0 -982
  118. brawny/_rpc/selector.py +0 -156
  119. brawny/db/base_new.py +0 -165
  120. brawny/db/mappers.py +0 -182
  121. brawny/db/migrations/008_add_transactions.sql +0 -72
  122. brawny/db/ops/attempts.py +0 -108
  123. brawny/db/ops/blocks.py +0 -83
  124. brawny/db/ops/cache.py +0 -93
  125. brawny/db/ops/intents.py +0 -296
  126. brawny/db/ops/jobs.py +0 -110
  127. brawny/db/ops/nonces.py +0 -322
  128. brawny/db/postgres.py +0 -2535
  129. brawny/db/postgres_new.py +0 -196
  130. brawny/db/sqlite.py +0 -2733
  131. brawny/db/sqlite_new.py +0 -191
  132. brawny-0.1.13.dist-info/RECORD +0 -141
  133. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
  134. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
  135. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/top_level.txt +0 -0
brawny/api.py CHANGED
@@ -317,7 +317,7 @@ kv = _KVProxy()
317
317
 
318
318
 
319
319
  def _get_rpc_from_context():
320
- """Get RPC manager from job context or alert context."""
320
+ """Get RPC client from job context or alert context."""
321
321
  from brawny._context import _job_ctx, get_alert_context
322
322
 
323
323
  # Try job context first
@@ -337,10 +337,10 @@ def _get_rpc_from_context():
337
337
 
338
338
 
339
339
  class _RPCProxy:
340
- """Proxy object for accessing the RPC manager.
340
+ """Proxy object for accessing the RPC client.
341
341
 
342
342
  Works in both job hooks (check/build_intent) and alert hooks.
343
- Provides access to all RPCManager methods with failover and health tracking.
343
+ Provides access to ReadClient/BroadcastClient methods with retries.
344
344
 
345
345
  Usage:
346
346
  from brawny import rpc
@@ -349,12 +349,12 @@ class _RPCProxy:
349
349
  bal = rpc.get_balance(self.operator_address)
350
350
  gas = rpc.get_gas_price()
351
351
 
352
- def alert_failed(self, ctx):
352
+ def on_failure(self, ctx):
353
353
  bal = rpc.get_balance(self.operator_address) / 1e18
354
354
  """
355
355
 
356
356
  def __getattr__(self, name: str):
357
- """Delegate attribute access to the underlying RPCManager."""
357
+ """Delegate attribute access to the underlying RPC client."""
358
358
  return getattr(_get_rpc_from_context(), name)
359
359
 
360
360
 
@@ -362,6 +362,34 @@ class _RPCProxy:
362
362
  rpc = _RPCProxy()
363
363
 
364
364
 
365
+ def _get_http_from_context():
366
+ """Get approved HTTP client from job or alert context."""
367
+ from brawny._context import _job_ctx, get_alert_context
368
+
369
+ job_ctx = _job_ctx.get()
370
+ if job_ctx is not None:
371
+ return job_ctx.http
372
+
373
+ alert_ctx = get_alert_context()
374
+ if alert_ctx is not None and getattr(alert_ctx, "http", None) is not None:
375
+ return alert_ctx.http
376
+
377
+ raise LookupError(
378
+ "No active context. Must be called from within a job hook "
379
+ "(check/build_intent) or alert hook (alert_*)."
380
+ )
381
+
382
+
383
+ class _HTTPProxy:
384
+ """Proxy object for accessing approved HTTP client."""
385
+
386
+ def __getattr__(self, name: str):
387
+ return getattr(_get_http_from_context(), name)
388
+
389
+
390
+ http = _HTTPProxy()
391
+
392
+
365
393
  # Thread-safe keystore address resolver - set once at startup
366
394
  import threading
367
395
 
@@ -419,7 +447,7 @@ def get_address_from_alias(alias: str) -> str:
419
447
  Usage:
420
448
  from brawny import get_address_from_alias
421
449
 
422
- def alert_failed(self, ctx):
450
+ def on_failure(self, ctx):
423
451
  addr = get_address_from_alias("yearn-worker")
424
452
  bal = rpc.get_balance(addr) / 1e18
425
453
  """
@@ -442,8 +470,8 @@ def shorten(hex_string: str, prefix: int = 6, suffix: int = 4) -> str:
442
470
  Example:
443
471
  from brawny import shorten
444
472
 
445
- def alert_confirmed(self, ctx):
446
- return f"Tx: {shorten(ctx.receipt.transactionHash.hex())}"
473
+ def on_success(self, ctx):
474
+ ctx.alert(f"Tx: {shorten(ctx.receipt.transaction_hash)}")
447
475
  """
448
476
  from brawny.alerts.base import shorten as _shorten
449
477
 
@@ -471,8 +499,8 @@ def explorer_link(
471
499
  Example:
472
500
  from brawny import explorer_link
473
501
 
474
- def alert_confirmed(self, ctx):
475
- return f"Done!\\n{explorer_link(ctx.receipt.transactionHash.hex())}"
502
+ def on_success(self, ctx):
503
+ ctx.alert(f"Done!\\n{explorer_link(ctx.receipt.transaction_hash)}")
476
504
  """
477
505
  from brawny._context import get_alert_context
478
506
  from brawny.alerts.base import explorer_link as _explorer_link
@@ -578,7 +606,7 @@ def Wei(value: str | int) -> int:
578
606
  class _Web3Proxy:
579
607
  """Proxy for context-aware web3 access.
580
608
 
581
- Provides full web3-py API while using RPCManager's health-tracked endpoints.
609
+ Provides full web3-py API while using the current RPC client's endpoints.
582
610
  Works in job hooks (check/build_intent) and alert hooks.
583
611
 
584
612
  Usage:
@@ -0,0 +1,325 @@
1
+ # Agent Guide: Build a Compliant brawny Job
2
+
3
+ This file is meant for user agents that generate new job files. It is a fast, practical spec.
4
+
5
+ ## Golden Rules
6
+ - Avoid over-engineering.
7
+ - Aim for simplicity and elegance.
8
+
9
+ ## Job File Checklist (Minimal)
10
+ - Location: `jobs/<job_name>.py`
11
+ - Import `Job` and `job`.
12
+ - Add `@job` decorator (omit it to hide a WIP job from discovery/validation).
13
+ - Implement `check()` (sync or async).
14
+ - If it sends a transaction, implement `build_intent()` (sync).
15
+
16
+ ## Required vs Optional Hooks
17
+
18
+ ### Required
19
+ - `check(self) -> Trigger | None` OR `check(self, ctx) -> Trigger | None`
20
+ - Must return `trigger(...)` or `None`.
21
+ - **Implicit style** `def check(self):` - use API helpers (`block`, `kv`, `Contract`, `ctx()`).
22
+ - **Explicit style** `def check(self, ctx):` - ctx passed directly (param MUST be named 'ctx').
23
+ - Can be async: `async def check(self)` or `async def check(self, ctx)`.
24
+
25
+ ### Required only for tx jobs
26
+ - `build_intent(self, trigger) -> TxIntentSpec`
27
+ - Build calldata and return `intent(...)`.
28
+ - Only called if `trigger.tx_required` is True.
29
+
30
+ ### Optional simulation hook
31
+ - `validate_simulation(self, output) -> bool`
32
+ - Return False to fail the intent after a successful simulation.
33
+
34
+ ### Optional lifecycle hooks (for alerts and custom logic)
35
+ - `on_trigger(self, ctx: TriggerContext)` - Called when job triggers, BEFORE build_intent().
36
+ - `on_success(self, ctx: SuccessContext)` - Called after TX confirms.
37
+ - `on_failure(self, ctx: FailureContext)` - Called on failure (ctx.intent may be None pre-intent).
38
+
39
+ All hooks have `ctx.alert(message)` for sending alerts to job destinations.
40
+
41
+ ## Job Class Attributes
42
+
43
+ ### Required (auto-derived if not set and @job is used)
44
+ - `job_id: str` - Stable identifier (must not change).
45
+ - `name: str` - Human-readable name for logs/alerts.
46
+
47
+ ### Optional scheduling
48
+ - `check_interval_blocks: int = 1` - Min blocks between check() calls.
49
+ - `check_timeout_seconds: int = 30` - Timeout for check().
50
+ - `build_timeout_seconds: int = 10` - Timeout for build_intent().
51
+ - `max_in_flight_intents: int | None = None` - Cap on active intents.
52
+
53
+ ### Optional gas overrides (all values in wei)
54
+ - `max_fee: int | None = None` - Max fee cap for gating/txs (None = no gating).
55
+ - `priority_fee: int | None = None` - Tip override for this job.
56
+
57
+ ### Optional simulation
58
+ - `disable_simulation: bool = False` - Skip pre-broadcast simulation.
59
+ - `rpc: str | None = None` - Override RPC for simulation.
60
+
61
+ ### Broadcast routing (via @job decorator)
62
+ Configure broadcast routing using the `@job` decorator:
63
+ ```python
64
+ @job(job_id="arb_exec", rpc_group="flashbots", signer="hot1")
65
+ class ArbitrageExecutor(Job):
66
+ ...
67
+ ```
68
+ - `job_id` - Optional override (defaults to snake_case of class name).
69
+ - `rpc_group` - Name of RPC group for reads and broadcasts.
70
+ - `broadcast_group` - Name of RPC group for broadcasts (default: uses rpc_default_group).
71
+ - `read_group` - Name of RPC group for read operations (default: uses rpc_default_group).
72
+ - `signer` - Name of signer alias (required for tx jobs).
73
+
74
+ Define RPC groups in config:
75
+ ```yaml
76
+ rpc_groups:
77
+ primary:
78
+ endpoints:
79
+ - https://eth.llamarpc.com
80
+ private:
81
+ endpoints:
82
+ - https://rpc.flashbots.net
83
+ - https://relay.flashbots.net
84
+ rpc_default_group: primary
85
+ ```
86
+
87
+ ### Alert routing
88
+ - `@job(alert_to="ops")` - Route alerts to named chat defined in config.
89
+ - `@job(alert_to=["ops", "dev"])` - Route to multiple chats.
90
+ - Names must be defined in `telegram.chats` config section.
91
+ - If not specified, uses `telegram.default` from config.
92
+
93
+ ## Core API (What to Use)
94
+
95
+ ### Contract access (brownie-style)
96
+ ```python
97
+ from brawny import Contract
98
+ vault = Contract(self.vault_address) # By address
99
+ decimals = vault.decimals() # View call
100
+ data = vault.harvest.encode_input() # Get calldata
101
+ ```
102
+
103
+ ### JSON interfaces (brownie-style)
104
+ Place ABI JSON files in `./interfaces`, then:
105
+ ```python
106
+ from brawny import interface
107
+ token = interface.IERC20("0x1234...")
108
+ balance = token.balanceOf("0xabc...")
109
+ ```
110
+
111
+ ### Job hook helpers (implicit context)
112
+ ```python
113
+ from brawny import trigger, intent, block, gas_ok
114
+ return trigger(reason="...", data={...}, idempotency_parts=[block.number])
115
+ return intent(signer_address="worker", to_address=addr, data=calldata)
116
+ ```
117
+
118
+ ### Event access in lifecycle hooks
119
+ ```python
120
+ def on_success(self, ctx: SuccessContext):
121
+ if ctx.events:
122
+ deposit = ctx.events[0] # First decoded event
123
+ amount = deposit["amount"] # Field access
124
+ ctx.alert(f"Deposited {amount}")
125
+ ```
126
+
127
+ ### Other context access
128
+ - `ctx()` - Get full CheckContext/BuildContext when using implicit style.
129
+ - `block.number`, `block.timestamp` - Current block info.
130
+ - `rpc.*` - RPC manager proxy (e.g., `rpc.get_gas_price()`).
131
+ - `ctx.http` - Approved HTTP client for external calls.
132
+ - `gas_ok()` - Check if current gas is below job's max_fee (async).
133
+ - `gas_quote()` - Get current base_fee (async).
134
+ - `kv.get(key, default=None)`, `kv.set(key, value)` - Persistent KV store (import from brawny).
135
+
136
+ ### Network access
137
+ - External HTTP calls must use `ctx.http` (ApprovedHttpClient).
138
+ - Direct network calls via `requests`, `urllib`, or raw `httpx` are blocked by network_guard.
139
+
140
+ ### Accounts
141
+ - Use `intent(signer_address=...)` with a signer alias or address.
142
+ - If you set `@job(signer="alias")`, use `self.signer` (alias) or `self.signer_address` (resolved address).
143
+ - The signer alias must exist in the accounts directory (`~/.brawny/accounts`).
144
+
145
+ ## Example: Transaction Job
146
+
147
+ ```python
148
+ from brawny import Job, job, Contract, trigger, intent, block
149
+
150
+ @job(signer="worker")
151
+ class MyKeeperJob(Job):
152
+ job_id = "my_keeper"
153
+ name = "My Keeper"
154
+ check_interval_blocks = 1
155
+ keeper_address = "0x..."
156
+
157
+ def check(self, ctx):
158
+ keeper = Contract(self.keeper_address)
159
+ if keeper.canWork():
160
+ return trigger(
161
+ reason="Keeper can work",
162
+ idempotency_parts=[block.number],
163
+ )
164
+ return None
165
+
166
+ def build_intent(self, trig):
167
+ keeper = Contract(self.keeper_address)
168
+ return intent(
169
+ signer_address=self.signer,
170
+ to_address=self.keeper_address,
171
+ data=keeper.work.encode_input(),
172
+ )
173
+ ```
174
+
175
+ ## Example: Job with Custom Broadcast and Alerts
176
+
177
+ ```python
178
+ from brawny import Job, Contract, trigger, intent, explorer_link
179
+ from brawny.jobs.registry import job
180
+
181
+ @job(rpc_group="flashbots", signer="treasury-signer", alert_to="private_ops")
182
+ class TreasuryJob(Job):
183
+ """Critical treasury operations with dedicated RPC and private alerts."""
184
+
185
+ name = "Treasury Operations"
186
+ check_interval_blocks = 1
187
+ treasury_address = "0x..."
188
+
189
+ def check(self, ctx):
190
+ treasury = Contract(self.treasury_address)
191
+ if treasury.needsRebalance():
192
+ return trigger(reason="Treasury needs rebalancing")
193
+ return None
194
+
195
+ def build_intent(self, trig):
196
+ treasury = Contract(self.treasury_address)
197
+ return intent(
198
+ signer_address=self.signer,
199
+ to_address=self.treasury_address,
200
+ data=treasury.rebalance.encode_input(),
201
+ )
202
+
203
+ def on_success(self, ctx):
204
+ ctx.alert(f"Treasury rebalanced: {explorer_link(ctx.receipt.transactionHash)}")
205
+ ```
206
+
207
+ ## Example: Monitor-Only Job (Implicit Context Style)
208
+
209
+ ```python
210
+ from brawny import Job, job, Contract, trigger, kv
211
+
212
+ @job
213
+ class MonitorJob(Job):
214
+ job_id = "monitor"
215
+ name = "Monitor"
216
+
217
+ def check(self): # No ctx param - uses implicit context
218
+ value = Contract("0x...").value()
219
+ last = kv.get("last", 0)
220
+ if value > last:
221
+ kv.set("last", value)
222
+ return trigger(
223
+ reason="Value increased",
224
+ data={"value": value},
225
+ tx_required=False,
226
+ )
227
+ return None
228
+ ```
229
+
230
+ ## Natural-Language -> Job Translation Guide
231
+
232
+ When a user says:
233
+ - **"Check X every block"** -> `check_interval_blocks = 1`
234
+ - **"Only run if gas below Y"** -> set `max_fee` (wei) and use `await gas_ok()` in async check()
235
+ - **"Use signer Z"** -> `@job(signer="Z")` and use `self.signer` in `intent(...)`
236
+ - **"Alert on success/failure"** -> implement `on_success` / `on_failure` with `ctx.alert()`
237
+ - **"Remember last value"** -> use `kv.get/set` (import from brawny)
238
+ - **"Use Flashbots"** -> `@job(rpc_group="flashbots")` with flashbots group in config
239
+ - **"Send alerts to private channel"** -> `@job(alert_to="private_ops")` with chat in config
240
+
241
+ ## Failure Modes
242
+
243
+ The `on_failure` hook provides rich context about what failed and when.
244
+
245
+ ### Failure Classification
246
+
247
+ **FailureType** (what failed):
248
+ - `SIMULATION_REVERTED` - TX would revert on-chain (permanent)
249
+ - `SIMULATION_NETWORK_ERROR` - RPC error during simulation (transient)
250
+ - `DEADLINE_EXPIRED` - Intent took too long (permanent)
251
+ - `SIGNER_FAILED` - Keystore/signer issue
252
+ - `NONCE_FAILED` - Couldn't reserve nonce
253
+ - `SIGN_FAILED` - Signing error
254
+ - `BROADCAST_FAILED` - RPC rejected transaction (transient)
255
+ - `TX_REVERTED` - On-chain revert (permanent)
256
+ - `NONCE_CONSUMED` - Nonce used by another transaction
257
+ - `CHECK_EXCEPTION` - job.check() raised an exception
258
+ - `BUILD_TX_EXCEPTION` - job.build_tx() raised an exception
259
+ - `UNKNOWN` - Fallback for unexpected failures
260
+
261
+ **FailureStage** (when it failed):
262
+ - `PRE_BROADCAST` - Failed before reaching the chain
263
+ - `BROADCAST` - Failed during broadcast
264
+ - `POST_BROADCAST` - Failed after broadcast (on-chain)
265
+
266
+ ### FailureContext in on_failure
267
+
268
+ ```python
269
+ # FailureContext fields
270
+ ctx.intent # TxIntent | None (None for pre-intent failures)
271
+ ctx.attempt # TxAttempt | None
272
+ ctx.error # Exception that caused failure
273
+ ctx.failure_type # FailureType enum
274
+ ctx.failure_stage # FailureStage | None
275
+ ctx.block # BlockContext
276
+ ctx.kv # KVReader (read-only)
277
+ ctx.logger # Bound logger
278
+
279
+ # FailureContext methods
280
+ ctx.alert(message) # Send alert to job destinations
281
+ ```
282
+
283
+ ### Example: Handling Failures
284
+
285
+ ```python
286
+ from brawny import Job, job
287
+ from brawny.model.errors import FailureType
288
+ from brawny.model.contexts import FailureContext
289
+
290
+ @job
291
+ class RobustJob(Job):
292
+ job_id = "robust_job"
293
+ name = "Robust Job"
294
+
295
+ def on_failure(self, ctx: FailureContext):
296
+ # Suppress alerts for transient failures
297
+ if ctx.failure_type in (
298
+ FailureType.SIMULATION_NETWORK_ERROR,
299
+ FailureType.BROADCAST_FAILED,
300
+ ):
301
+ return # No alert for transient failures
302
+
303
+ # Detailed message for permanent failures
304
+ if ctx.failure_type == FailureType.SIMULATION_REVERTED:
305
+ ctx.alert(f"TX would revert: {ctx.error}")
306
+ elif ctx.failure_type == FailureType.TX_REVERTED:
307
+ ctx.alert(f"TX reverted on-chain: {ctx.error}")
308
+ elif ctx.failure_type == FailureType.NONCE_CONSUMED:
309
+ ctx.alert("Nonce conflict! Check signer activity.")
310
+ elif ctx.failure_type == FailureType.CHECK_EXCEPTION:
311
+ ctx.alert(f"check() crashed: {ctx.error}")
312
+ elif ctx.failure_type == FailureType.BUILD_TX_EXCEPTION:
313
+ ctx.alert(f"build_intent() crashed: {ctx.error}")
314
+ else:
315
+ ctx.alert(f"Failed ({ctx.failure_type.value}): {ctx.error}")
316
+ ```
317
+
318
+ ## Required Output from Agent
319
+ When generating a new job file, the agent must provide:
320
+ - File path
321
+ - Job class name
322
+ - `job_id` and `name`
323
+ - `check()` implementation
324
+ - `build_intent()` if tx required
325
+ - Any alert hooks requested
@@ -0,0 +1,48 @@
1
+ """Async runtime boundary helpers.
2
+
3
+ Provides a single owned event loop and safe sync/async adapters.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import threading
10
+ from typing import Coroutine, TypeVar
11
+
12
+ T = TypeVar("T")
13
+
14
+ _loop: asyncio.AbstractEventLoop | None = None
15
+ _loop_thread_id: int | None = None
16
+ _lock = threading.Lock()
17
+
18
+
19
+ def register_loop(loop: asyncio.AbstractEventLoop, loop_thread_id: int) -> None:
20
+ """Register the owned event loop and its thread id."""
21
+ global _loop, _loop_thread_id
22
+ with _lock:
23
+ _loop = loop
24
+ _loop_thread_id = loop_thread_id
25
+
26
+
27
+ def clear_loop() -> None:
28
+ """Clear the owned event loop (shutdown)."""
29
+ global _loop, _loop_thread_id
30
+ with _lock:
31
+ _loop = None
32
+ _loop_thread_id = None
33
+
34
+
35
+ def run_sync(coro: Coroutine[object, object, T]) -> T:
36
+ """Run a coroutine on the owned loop from sync code."""
37
+ loop = _loop
38
+ if loop is None:
39
+ raise RuntimeError("run_sync called before the async loop is registered")
40
+ if _loop_thread_id is not None and threading.get_ident() == _loop_thread_id:
41
+ raise RuntimeError("Called run_sync from the loop thread — await the coroutine instead.")
42
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
43
+ return future.result()
44
+
45
+
46
+ async def to_thread(func, /, *args, **kwargs):
47
+ """Run sync work in a worker thread from async code."""
48
+ return await asyncio.to_thread(func, *args, **kwargs)
brawny/chain.py CHANGED
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  from typing import Any, TYPE_CHECKING
15
15
 
16
16
  if TYPE_CHECKING:
17
- from brawny._rpc.manager import RPCManager
17
+ from brawny._rpc.clients import ReadClient
18
18
 
19
19
 
20
20
  _chain: "Chain | None" = None
@@ -26,7 +26,7 @@ class Chain:
26
26
  Provides access to block information and chain state.
27
27
  """
28
28
 
29
- def __init__(self, rpc: "RPCManager", chain_id: int) -> None:
29
+ def __init__(self, rpc: "ReadClient", chain_id: int) -> None:
30
30
  self._rpc = rpc
31
31
  self._chain_id = chain_id
32
32
 
@@ -55,7 +55,7 @@ class Chain:
55
55
  return f"<Chain id={self._chain_id} height={self.height}>"
56
56
 
57
57
 
58
- def _init_chain(rpc: "RPCManager", chain_id: int) -> None:
58
+ def _init_chain(rpc: "ReadClient", chain_id: int) -> None:
59
59
  """Initialize global chain singleton."""
60
60
  global _chain
61
61
  _chain = Chain(rpc, chain_id)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from brawny.cli.commands.abi import register as register_abi
6
6
  from brawny.cli.commands.contract import register as register_contract
7
+ from brawny.cli.commands.controls import register as register_controls
7
8
  from brawny.cli.commands.health import register as register_health
8
9
  from brawny.cli.commands.init_project import register as register_init
9
10
  from brawny.cli.commands.intents import register as register_intents
@@ -33,6 +34,7 @@ def register_all(main) -> None:
33
34
  register_accounts(main)
34
35
  register_networks(main)
35
36
  register_signer(main) # brawny signer force-reset, status
37
+ register_controls(main)
36
38
  # Console has optional dependency (prompt_toolkit) - import lazily
37
39
  try:
38
40
  from brawny.cli.commands.console import register as register_console
@@ -139,6 +139,32 @@ def _format_syntaxerror(exc: SyntaxError) -> str:
139
139
  return result
140
140
 
141
141
 
142
+ def _assert_console_allowed(config) -> None:
143
+ """Deprecated console gate (kept for compatibility)."""
144
+ return None
145
+
146
+
147
+ def _resolve_completion_base(locals_dict: dict, base: str) -> Any:
148
+ """Resolve completion base without executing descriptors/properties."""
149
+ import inspect
150
+
151
+ parts = base.split(".")
152
+ if not parts or not re.match(r"^[A-Za-z_]\\w*$", parts[0]):
153
+ raise ValueError("invalid base")
154
+ obj = locals_dict[parts[0]]
155
+ if len(parts) == 1:
156
+ return obj
157
+ if len(parts) > 2:
158
+ raise ValueError("unsafe depth")
159
+ attr = parts[1]
160
+ if not re.match(r"^[A-Za-z_]\\w*$", attr):
161
+ raise ValueError("invalid attr")
162
+ candidate = inspect.getattr_static(obj, attr)
163
+ if hasattr(candidate, "__get__"):
164
+ raise ValueError("unsafe attr")
165
+ return candidate
166
+
167
+
142
168
  class ConsoleCompleter(Completer):
143
169
  """Dropdown tab-completion for the console (mirrors Brownie)."""
144
170
 
@@ -161,8 +187,8 @@ class ConsoleCompleter(Completer):
161
187
  base, partial = expr.rsplit(".", 1)
162
188
 
163
189
  try:
164
- obj = eval(base, self.console.locals) # noqa: S307
165
- except Exception:
190
+ obj = _resolve_completion_base(self.console.locals, base)
191
+ except (KeyError, AttributeError, ValueError, TypeError):
166
192
  return
167
193
 
168
194
  for attr in dir(obj):
@@ -171,11 +197,14 @@ class ConsoleCompleter(Completer):
171
197
  if attr.startswith("_") and not partial.startswith("_"):
172
198
  continue
173
199
 
174
- # Add ( for callables
200
+ suffix = ""
175
201
  try:
176
- val = getattr(obj, attr)
177
- suffix = "(" if callable(val) else ""
178
- except Exception:
202
+ import inspect
203
+
204
+ val = inspect.getattr_static(obj, attr)
205
+ if callable(val) and not isinstance(val, property):
206
+ suffix = "("
207
+ except (AttributeError, ValueError, TypeError):
179
208
  suffix = ""
180
209
 
181
210
  yield Completion(attr + suffix, start_position=-len(partial))
@@ -186,7 +215,7 @@ class ConsoleCompleter(Completer):
186
215
  if name.startswith("_") and not expr.startswith("_"):
187
216
  continue
188
217
  yield Completion(name, start_position=-len(expr))
189
- except Exception:
218
+ except (AttributeError, KeyError, ValueError, TypeError):
190
219
  pass # Fail silently - no completions
191
220
 
192
221
 
@@ -254,7 +283,7 @@ class BrawnyConsole(code.InteractiveConsole):
254
283
  highlighted = highlight(result_str, PythonLexer(), self._formatter)
255
284
  self.write(highlighted)
256
285
  return False
257
- except Exception:
286
+ except (SyntaxError, ValueError, TypeError):
258
287
  pass
259
288
 
260
289
  # Not an expression - run as statement
@@ -262,7 +291,7 @@ class BrawnyConsole(code.InteractiveConsole):
262
291
  return False
263
292
 
264
293
 
265
- @click.command("console")
294
+ @click.command("console", hidden=True)
266
295
  @click.option("--config", "config_path", default="./config.yaml", help="Path to config.yaml")
267
296
  @click.option("--debug", is_flag=True, help="Enable debug logging")
268
297
  def console(config_path: str, debug: bool) -> None:
@@ -279,6 +308,17 @@ def console(config_path: str, debug: bool) -> None:
279
308
  import logging
280
309
  import structlog
281
310
 
311
+ if not os.path.exists(config_path):
312
+ click.echo(f"Config file not found: {config_path}", err=True)
313
+ sys.exit(1)
314
+
315
+ from brawny.config import Config
316
+
317
+ config = Config.from_yaml(config_path)
318
+ config, _ = config.apply_env_overrides()
319
+
320
+ _assert_console_allowed(config)
321
+
282
322
  # Suppress all logs during startup for clean console UX
283
323
  # Must configure both stdlib logging AND structlog to silence output
284
324
  if not debug:
@@ -298,17 +338,23 @@ def console(config_path: str, debug: bool) -> None:
298
338
  from brawny.logging import setup_logging, LogFormat
299
339
  setup_logging(log_level="DEBUG", log_format=LogFormat.TEXT)
300
340
 
301
- from brawny.alerts.contracts import ContractSystem
302
- from brawny.config import Config
303
- from brawny._rpc import RPCManager
341
+ from brawny.logging import get_logger
304
342
 
305
- if not os.path.exists(config_path):
306
- click.echo(f"Config file not found: {config_path}", err=True)
307
- sys.exit(1)
343
+ import socket
308
344
 
309
- config = Config.from_yaml(config_path)
310
- config, _ = config.apply_env_overrides()
345
+ allow_env = os.environ.get("BRAWNY_ALLOW_CONSOLE") == "1"
346
+ logger = get_logger(__name__)
347
+ logger.warning(
348
+ "console.opened",
349
+ user=os.environ.get("USER", "unknown"),
350
+ hostname=socket.gethostname(),
351
+ allow_env=allow_env,
352
+ config_allow=config.debug.allow_console,
353
+ tty=sys.stdin.isatty(),
354
+ )
311
355
 
356
+ from brawny.alerts.contracts import ContractSystem
357
+ from brawny._rpc.clients import BroadcastClient
312
358
  from brawny.config.routing import resolve_default_group
313
359
 
314
360
  rpc_group = resolve_default_group(config)
@@ -319,11 +365,15 @@ def console(config_path: str, debug: bool) -> None:
319
365
  click.echo("No RPC endpoints configured", err=True)
320
366
  sys.exit(1)
321
367
 
322
- # Create RPC manager with selected endpoints
323
- rpc = RPCManager(
368
+ # Create broadcast client with selected endpoints
369
+ from brawny._rpc.retry_policy import broadcast_policy
370
+
371
+ rpc = BroadcastClient(
324
372
  endpoints=rpc_endpoints,
325
373
  timeout_seconds=config.rpc_timeout_seconds,
326
374
  max_retries=config.rpc_max_retries,
375
+ retry_backoff_base=config.rpc_retry_backoff_base,
376
+ retry_policy=broadcast_policy(config),
327
377
  )
328
378
 
329
379
  # ContractSystem uses global ABI cache at ~/.brawny/abi_cache.db