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.
- brawny/__init__.py +2 -0
- brawny/_context.py +5 -5
- brawny/_rpc/__init__.py +36 -12
- brawny/_rpc/broadcast.py +14 -13
- brawny/_rpc/caller.py +243 -0
- brawny/_rpc/client.py +539 -0
- brawny/_rpc/clients.py +11 -11
- brawny/_rpc/context.py +23 -0
- brawny/_rpc/errors.py +465 -31
- brawny/_rpc/gas.py +7 -6
- brawny/_rpc/pool.py +18 -0
- brawny/_rpc/retry.py +266 -0
- brawny/_rpc/retry_policy.py +81 -0
- brawny/accounts.py +28 -9
- brawny/alerts/__init__.py +15 -18
- brawny/alerts/abi_resolver.py +212 -36
- brawny/alerts/base.py +2 -2
- brawny/alerts/contracts.py +77 -10
- brawny/alerts/errors.py +30 -3
- brawny/alerts/events.py +38 -5
- brawny/alerts/health.py +19 -13
- brawny/alerts/send.py +513 -55
- brawny/api.py +39 -11
- brawny/assets/AGENTS.md +325 -0
- brawny/async_runtime.py +48 -0
- brawny/chain.py +3 -3
- brawny/cli/commands/__init__.py +2 -0
- brawny/cli/commands/console.py +69 -19
- brawny/cli/commands/contract.py +2 -2
- brawny/cli/commands/controls.py +121 -0
- brawny/cli/commands/health.py +2 -2
- brawny/cli/commands/job_dev.py +6 -5
- brawny/cli/commands/jobs.py +99 -2
- brawny/cli/commands/maintenance.py +13 -29
- brawny/cli/commands/migrate.py +1 -0
- brawny/cli/commands/run.py +10 -3
- brawny/cli/commands/script.py +8 -3
- brawny/cli/commands/signer.py +143 -26
- brawny/cli/helpers.py +0 -3
- brawny/cli_templates.py +25 -349
- brawny/config/__init__.py +4 -1
- brawny/config/models.py +43 -57
- brawny/config/parser.py +268 -57
- brawny/config/validation.py +52 -15
- brawny/daemon/context.py +4 -2
- brawny/daemon/core.py +185 -63
- brawny/daemon/loops.py +166 -98
- brawny/daemon/supervisor.py +261 -0
- brawny/db/__init__.py +14 -26
- brawny/db/base.py +248 -151
- brawny/db/global_cache.py +11 -1
- brawny/db/migrate.py +175 -28
- brawny/db/migrations/001_init.sql +4 -3
- brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
- brawny/db/migrations/011_add_job_logs.sql +1 -2
- brawny/db/migrations/012_add_claimed_by.sql +2 -2
- brawny/db/migrations/013_attempt_unique.sql +10 -0
- brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
- brawny/db/migrations/015_add_signer_alias.sql +14 -0
- brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
- brawny/db/migrations/017_add_job_drain.sql +6 -0
- brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
- brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
- brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
- brawny/db/ops/__init__.py +3 -25
- brawny/db/ops/logs.py +1 -2
- brawny/db/queries.py +47 -91
- brawny/db/serialized.py +65 -0
- brawny/db/sqlite/__init__.py +1001 -0
- brawny/db/sqlite/connection.py +231 -0
- brawny/db/sqlite/execute.py +116 -0
- brawny/db/sqlite/mappers.py +190 -0
- brawny/db/sqlite/repos/attempts.py +372 -0
- brawny/db/sqlite/repos/block_state.py +102 -0
- brawny/db/sqlite/repos/cache.py +104 -0
- brawny/db/sqlite/repos/intents.py +1021 -0
- brawny/db/sqlite/repos/jobs.py +200 -0
- brawny/db/sqlite/repos/maintenance.py +182 -0
- brawny/db/sqlite/repos/signers_nonces.py +566 -0
- brawny/db/sqlite/tx.py +119 -0
- brawny/http.py +194 -0
- brawny/invariants.py +11 -24
- brawny/jobs/base.py +8 -0
- brawny/jobs/job_validation.py +2 -1
- brawny/keystore.py +83 -7
- brawny/lifecycle.py +64 -12
- brawny/logging.py +0 -2
- brawny/metrics.py +84 -12
- brawny/model/contexts.py +111 -9
- brawny/model/enums.py +1 -0
- brawny/model/errors.py +18 -0
- brawny/model/types.py +47 -131
- brawny/network_guard.py +133 -0
- brawny/networks/__init__.py +5 -5
- brawny/networks/config.py +1 -7
- brawny/networks/manager.py +14 -11
- brawny/runtime_controls.py +74 -0
- brawny/scheduler/poller.py +11 -7
- brawny/scheduler/reorg.py +95 -39
- brawny/scheduler/runner.py +442 -168
- brawny/scheduler/shutdown.py +3 -3
- brawny/script_tx.py +3 -3
- brawny/telegram.py +53 -7
- brawny/testing.py +1 -0
- brawny/timeout.py +38 -0
- brawny/tx/executor.py +922 -308
- brawny/tx/intent.py +54 -16
- brawny/tx/monitor.py +31 -12
- brawny/tx/nonce.py +212 -90
- brawny/tx/replacement.py +69 -18
- brawny/tx/retry_policy.py +24 -0
- brawny/tx/stages/types.py +75 -0
- brawny/types.py +18 -0
- brawny/utils.py +41 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
- brawny-0.1.22.dist-info/RECORD +163 -0
- brawny/_rpc/manager.py +0 -982
- brawny/_rpc/selector.py +0 -156
- brawny/db/base_new.py +0 -165
- brawny/db/mappers.py +0 -182
- brawny/db/migrations/008_add_transactions.sql +0 -72
- brawny/db/ops/attempts.py +0 -108
- brawny/db/ops/blocks.py +0 -83
- brawny/db/ops/cache.py +0 -93
- brawny/db/ops/intents.py +0 -296
- brawny/db/ops/jobs.py +0 -110
- brawny/db/ops/nonces.py +0 -322
- brawny/db/postgres.py +0 -2535
- brawny/db/postgres_new.py +0 -196
- brawny/db/sqlite.py +0 -2733
- brawny/db/sqlite_new.py +0 -191
- brawny-0.1.13.dist-info/RECORD +0 -141
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
446
|
-
|
|
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
|
|
475
|
-
|
|
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
|
|
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:
|
brawny/assets/AGENTS.md
ADDED
|
@@ -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
|
brawny/async_runtime.py
ADDED
|
@@ -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.
|
|
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: "
|
|
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: "
|
|
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)
|
brawny/cli/commands/__init__.py
CHANGED
|
@@ -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
|
brawny/cli/commands/console.py
CHANGED
|
@@ -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 =
|
|
165
|
-
except
|
|
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
|
-
|
|
200
|
+
suffix = ""
|
|
175
201
|
try:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
|
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.
|
|
302
|
-
from brawny.config import Config
|
|
303
|
-
from brawny._rpc import RPCManager
|
|
341
|
+
from brawny.logging import get_logger
|
|
304
342
|
|
|
305
|
-
|
|
306
|
-
click.echo(f"Config file not found: {config_path}", err=True)
|
|
307
|
-
sys.exit(1)
|
|
343
|
+
import socket
|
|
308
344
|
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
323
|
-
|
|
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
|