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/cli/commands/signer.py
CHANGED
|
@@ -40,20 +40,39 @@ def signer() -> None:
|
|
|
40
40
|
|
|
41
41
|
@signer.command("force-reset")
|
|
42
42
|
@click.argument("address_or_alias")
|
|
43
|
+
@click.option("--target-nonce", "-n", type=int, default=None,
|
|
44
|
+
help="Explicit target nonce to reset to")
|
|
45
|
+
@click.option("--use-rpc-pending", is_flag=True,
|
|
46
|
+
help="Use RPC pending nonce as target (required if --target-nonce not set)")
|
|
47
|
+
@click.option("--reason", "-r", default=None,
|
|
48
|
+
help="Reason for reset (required for audit trail)")
|
|
43
49
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
44
50
|
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
45
|
-
def force_reset(
|
|
51
|
+
def force_reset(
|
|
52
|
+
address_or_alias: str,
|
|
53
|
+
target_nonce: int | None,
|
|
54
|
+
use_rpc_pending: bool,
|
|
55
|
+
reason: str | None,
|
|
56
|
+
yes: bool,
|
|
57
|
+
config_path: str | None,
|
|
58
|
+
) -> None:
|
|
46
59
|
"""Force reset nonce state for a signer. USE WITH CAUTION.
|
|
47
60
|
|
|
48
61
|
ADDRESS_OR_ALIAS can be:
|
|
49
62
|
- Full address: 0x1234567890abcdef1234567890abcdef12345678
|
|
50
63
|
- Signer alias: hot-wallet-1
|
|
51
64
|
|
|
65
|
+
You must specify either --target-nonce or --use-rpc-pending:
|
|
66
|
+
|
|
67
|
+
\b
|
|
68
|
+
brawny signer force-reset 0x... --target-nonce 42 --reason "stuck tx"
|
|
69
|
+
brawny signer force-reset 0x... --use-rpc-pending --reason "gap recovery"
|
|
70
|
+
|
|
52
71
|
This will:
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
- Release all reservations with nonce >= chain_pending_nonce
|
|
72
|
+
- Reset local next_nonce to the specified target
|
|
73
|
+
- Release all reservations with nonce >= target
|
|
56
74
|
- Clear gap tracking
|
|
75
|
+
- Log an audit trail with source and reason
|
|
57
76
|
|
|
58
77
|
WARNING: If any prior transactions later mine, you may have duplicate
|
|
59
78
|
transactions or nonce conflicts.
|
|
@@ -62,10 +81,37 @@ def force_reset(address_or_alias: str, yes: bool, config_path: str | None) -> No
|
|
|
62
81
|
|
|
63
82
|
from brawny.config import Config, get_config
|
|
64
83
|
from brawny.db import create_database
|
|
65
|
-
from brawny._rpc import
|
|
84
|
+
from brawny._rpc.clients import ReadClient
|
|
66
85
|
from brawny.tx.nonce import NonceManager
|
|
67
86
|
from brawny.model.enums import NonceStatus
|
|
68
87
|
|
|
88
|
+
# Validate: must specify either --target-nonce or --use-rpc-pending
|
|
89
|
+
if target_nonce is None and not use_rpc_pending:
|
|
90
|
+
click.echo(click.style(
|
|
91
|
+
"Error: Must specify either --target-nonce or --use-rpc-pending",
|
|
92
|
+
fg="red", bold=True
|
|
93
|
+
))
|
|
94
|
+
click.echo("\nExamples:")
|
|
95
|
+
click.echo(" brawny signer force-reset 0x... --target-nonce 42 --reason 'stuck tx'")
|
|
96
|
+
click.echo(" brawny signer force-reset 0x... --use-rpc-pending --reason 'gap recovery'")
|
|
97
|
+
raise SystemExit(1)
|
|
98
|
+
|
|
99
|
+
if target_nonce is not None and use_rpc_pending:
|
|
100
|
+
click.echo(click.style(
|
|
101
|
+
"Error: Cannot specify both --target-nonce and --use-rpc-pending",
|
|
102
|
+
fg="red", bold=True
|
|
103
|
+
))
|
|
104
|
+
raise SystemExit(1)
|
|
105
|
+
|
|
106
|
+
# Prompt for reason in interactive mode if not provided
|
|
107
|
+
if reason is None and not yes:
|
|
108
|
+
reason = click.prompt("Reason for reset (for audit trail)", default="")
|
|
109
|
+
if not reason:
|
|
110
|
+
click.echo(click.style(
|
|
111
|
+
"Warning: No reason provided. Continuing without audit reason.",
|
|
112
|
+
fg="yellow"
|
|
113
|
+
))
|
|
114
|
+
|
|
69
115
|
# Load config
|
|
70
116
|
if config_path:
|
|
71
117
|
config = Config.from_yaml(config_path)
|
|
@@ -74,14 +120,11 @@ def force_reset(address_or_alias: str, yes: bool, config_path: str | None) -> No
|
|
|
74
120
|
config = get_config()
|
|
75
121
|
|
|
76
122
|
# Connect to database
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
circuit_breaker_failures=config.db_circuit_breaker_failures,
|
|
83
|
-
circuit_breaker_seconds=config.db_circuit_breaker_seconds,
|
|
84
|
-
)
|
|
123
|
+
db = create_database(
|
|
124
|
+
config.database_url,
|
|
125
|
+
circuit_breaker_failures=config.db_circuit_breaker_failures,
|
|
126
|
+
circuit_breaker_seconds=config.db_circuit_breaker_seconds,
|
|
127
|
+
)
|
|
85
128
|
db.connect()
|
|
86
129
|
|
|
87
130
|
try:
|
|
@@ -89,7 +132,7 @@ def force_reset(address_or_alias: str, yes: bool, config_path: str | None) -> No
|
|
|
89
132
|
address = resolve_signer_address(db, config.chain_id, address_or_alias)
|
|
90
133
|
|
|
91
134
|
# Setup RPC and nonce manager
|
|
92
|
-
rpc =
|
|
135
|
+
rpc = ReadClient.from_config(config)
|
|
93
136
|
nonce_manager = NonceManager(db, rpc, config.chain_id)
|
|
94
137
|
|
|
95
138
|
# Get current state
|
|
@@ -99,10 +142,13 @@ def force_reset(address_or_alias: str, yes: bool, config_path: str | None) -> No
|
|
|
99
142
|
signer_state = db.get_signer_state(config.chain_id, address)
|
|
100
143
|
reservations = db.get_reservations_for_signer(config.chain_id, address)
|
|
101
144
|
|
|
145
|
+
# Determine effective target nonce
|
|
146
|
+
effective_target = target_nonce if target_nonce is not None else chain_pending
|
|
147
|
+
|
|
102
148
|
# Calculate affected reservations
|
|
103
149
|
affected = [
|
|
104
150
|
r for r in reservations
|
|
105
|
-
if r.nonce >=
|
|
151
|
+
if r.nonce >= effective_target and r.status in (NonceStatus.RESERVED, NonceStatus.IN_FLIGHT)
|
|
106
152
|
]
|
|
107
153
|
|
|
108
154
|
# Display current state
|
|
@@ -111,6 +157,7 @@ def force_reset(address_or_alias: str, yes: bool, config_path: str | None) -> No
|
|
|
111
157
|
click.echo(f"Alias: {address_or_alias}")
|
|
112
158
|
click.echo(f"Current chain pending nonce: {chain_pending}")
|
|
113
159
|
click.echo(f"Current local next_nonce: {signer_state.next_nonce if signer_state else 'N/A'}")
|
|
160
|
+
click.echo(f"Target nonce: {effective_target}" + (" (from RPC)" if use_rpc_pending else " (explicit)"))
|
|
114
161
|
click.echo(f"Reservations to release: {len(affected)}")
|
|
115
162
|
|
|
116
163
|
if affected:
|
|
@@ -130,7 +177,12 @@ def force_reset(address_or_alias: str, yes: bool, config_path: str | None) -> No
|
|
|
130
177
|
return
|
|
131
178
|
|
|
132
179
|
# Execute force reset
|
|
133
|
-
new_nonce = nonce_manager.force_reset(
|
|
180
|
+
new_nonce = nonce_manager.force_reset(
|
|
181
|
+
address,
|
|
182
|
+
source="cli",
|
|
183
|
+
reason=reason or "no reason provided",
|
|
184
|
+
target_nonce=effective_target,
|
|
185
|
+
)
|
|
134
186
|
click.echo(click.style(
|
|
135
187
|
f"\n✓ Reset complete. next_nonce now {new_nonce}",
|
|
136
188
|
fg="green", bold=True
|
|
@@ -154,7 +206,7 @@ def status(address_or_alias: str, config_path: str | None) -> None:
|
|
|
154
206
|
|
|
155
207
|
from brawny.config import Config, get_config
|
|
156
208
|
from brawny.db import create_database
|
|
157
|
-
from brawny._rpc import
|
|
209
|
+
from brawny._rpc.clients import ReadClient
|
|
158
210
|
from brawny.model.enums import NonceStatus
|
|
159
211
|
|
|
160
212
|
# Load config
|
|
@@ -165,14 +217,11 @@ def status(address_or_alias: str, config_path: str | None) -> None:
|
|
|
165
217
|
config = get_config()
|
|
166
218
|
|
|
167
219
|
# Connect to database
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
circuit_breaker_failures=config.db_circuit_breaker_failures,
|
|
174
|
-
circuit_breaker_seconds=config.db_circuit_breaker_seconds,
|
|
175
|
-
)
|
|
220
|
+
db = create_database(
|
|
221
|
+
config.database_url,
|
|
222
|
+
circuit_breaker_failures=config.db_circuit_breaker_failures,
|
|
223
|
+
circuit_breaker_seconds=config.db_circuit_breaker_seconds,
|
|
224
|
+
)
|
|
176
225
|
db.connect()
|
|
177
226
|
|
|
178
227
|
try:
|
|
@@ -180,7 +229,7 @@ def status(address_or_alias: str, config_path: str | None) -> None:
|
|
|
180
229
|
address = resolve_signer_address(db, config.chain_id, address_or_alias)
|
|
181
230
|
|
|
182
231
|
# Setup RPC
|
|
183
|
-
rpc =
|
|
232
|
+
rpc = ReadClient.from_config(config)
|
|
184
233
|
|
|
185
234
|
# Get current state
|
|
186
235
|
chain_pending = rpc.get_transaction_count(
|
|
@@ -205,6 +254,22 @@ def status(address_or_alias: str, config_path: str | None) -> None:
|
|
|
205
254
|
click.echo(f"\nLocal State:")
|
|
206
255
|
click.echo(f" next_nonce: {signer_state.next_nonce}")
|
|
207
256
|
click.echo(f" last_synced_chain_nonce: {signer_state.last_synced_chain_nonce}")
|
|
257
|
+
if signer_state.quarantined_at:
|
|
258
|
+
click.echo(
|
|
259
|
+
click.style(
|
|
260
|
+
f" quarantined_at: {signer_state.quarantined_at}",
|
|
261
|
+
fg="red",
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
if signer_state.quarantine_reason:
|
|
265
|
+
click.echo(
|
|
266
|
+
click.style(
|
|
267
|
+
f" quarantine_reason: {signer_state.quarantine_reason}",
|
|
268
|
+
fg="red",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
if signer_state.replacements_paused:
|
|
272
|
+
click.echo(click.style(" replacements_paused: true", fg="yellow"))
|
|
208
273
|
if signer_state.gap_started_at:
|
|
209
274
|
click.echo(click.style(
|
|
210
275
|
f" gap_started_at: {signer_state.gap_started_at} (BLOCKED)",
|
|
@@ -243,6 +308,58 @@ def status(address_or_alias: str, config_path: str | None) -> None:
|
|
|
243
308
|
db.close()
|
|
244
309
|
|
|
245
310
|
|
|
311
|
+
@signer.command("quarantine")
|
|
312
|
+
@click.argument("address_or_alias")
|
|
313
|
+
@click.option("--reason", "-r", default=None, help="Reason for quarantine")
|
|
314
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
315
|
+
def quarantine(address_or_alias: str, reason: str | None, config_path: str | None) -> None:
|
|
316
|
+
"""Quarantine a signer (blocks nonce reservations and broadcasts)."""
|
|
317
|
+
from brawny.cli.helpers import get_config, get_db
|
|
318
|
+
|
|
319
|
+
config = get_config(config_path)
|
|
320
|
+
db = get_db(config_path)
|
|
321
|
+
try:
|
|
322
|
+
address = resolve_signer_address(db, config.chain_id, address_or_alias)
|
|
323
|
+
updated = db.set_signer_quarantined(
|
|
324
|
+
config.chain_id,
|
|
325
|
+
address,
|
|
326
|
+
reason=reason,
|
|
327
|
+
actor="cli",
|
|
328
|
+
source="cli",
|
|
329
|
+
)
|
|
330
|
+
if updated:
|
|
331
|
+
click.echo(f"Signer '{address}' quarantined.")
|
|
332
|
+
else:
|
|
333
|
+
click.echo(f"Signer '{address}' not found.", err=True)
|
|
334
|
+
finally:
|
|
335
|
+
db.close()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@signer.command("unquarantine")
|
|
339
|
+
@click.argument("address_or_alias")
|
|
340
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
341
|
+
def unquarantine(address_or_alias: str, config_path: str | None) -> None:
|
|
342
|
+
"""Clear signer quarantine."""
|
|
343
|
+
from brawny.cli.helpers import get_config, get_db
|
|
344
|
+
|
|
345
|
+
config = get_config(config_path)
|
|
346
|
+
db = get_db(config_path)
|
|
347
|
+
try:
|
|
348
|
+
address = resolve_signer_address(db, config.chain_id, address_or_alias)
|
|
349
|
+
updated = db.clear_signer_quarantined(
|
|
350
|
+
config.chain_id,
|
|
351
|
+
address,
|
|
352
|
+
actor="cli",
|
|
353
|
+
source="cli",
|
|
354
|
+
)
|
|
355
|
+
if updated:
|
|
356
|
+
click.echo(f"Signer '{address}' unquarantined.")
|
|
357
|
+
else:
|
|
358
|
+
click.echo(f"Signer '{address}' not found.", err=True)
|
|
359
|
+
finally:
|
|
360
|
+
db.close()
|
|
361
|
+
|
|
362
|
+
|
|
246
363
|
def register(main) -> None:
|
|
247
364
|
"""Register signer commands with the main CLI."""
|
|
248
365
|
main.add_command(signer)
|
brawny/cli/helpers.py
CHANGED
|
@@ -64,9 +64,6 @@ def get_db(config_path: str | None = None):
|
|
|
64
64
|
|
|
65
65
|
db = create_database(
|
|
66
66
|
config.database_url,
|
|
67
|
-
pool_size=config.database_pool_size,
|
|
68
|
-
pool_max_overflow=config.database_pool_max_overflow,
|
|
69
|
-
pool_timeout=config.database_pool_timeout_seconds,
|
|
70
67
|
circuit_breaker_failures=config.db_circuit_breaker_failures,
|
|
71
68
|
circuit_breaker_seconds=config.db_circuit_breaker_seconds,
|
|
72
69
|
)
|
brawny/cli_templates.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Templates for brawny init command."""
|
|
2
2
|
|
|
3
|
+
import importlib.resources as resources
|
|
4
|
+
|
|
3
5
|
PYPROJECT_TEMPLATE = """\
|
|
4
6
|
[build-system]
|
|
5
7
|
requires = ["setuptools>=68.0", "wheel"]
|
|
@@ -35,7 +37,7 @@ chain_id: 1
|
|
|
35
37
|
keystore_type: file
|
|
36
38
|
keystore_path: ~/.brawny/keys
|
|
37
39
|
|
|
38
|
-
# SQLite requires worker_count: 1
|
|
40
|
+
# SQLite requires worker_count: 1 (single runner only).
|
|
39
41
|
worker_count: 1
|
|
40
42
|
|
|
41
43
|
# Prometheus metrics port (default: 9091)
|
|
@@ -48,6 +50,21 @@ worker_count: 1
|
|
|
48
50
|
# ops: "-1001234567890"
|
|
49
51
|
# default: ["ops"]
|
|
50
52
|
# parse_mode: "Markdown"
|
|
53
|
+
# health_chat: "ops"
|
|
54
|
+
# health_cooldown_seconds: 1800
|
|
55
|
+
|
|
56
|
+
# HTTP (optional, for job code)
|
|
57
|
+
# http:
|
|
58
|
+
# allowed_domains:
|
|
59
|
+
# - "api.coingecko.com"
|
|
60
|
+
# - "*.githubusercontent.com"
|
|
61
|
+
# connect_timeout_seconds: 5
|
|
62
|
+
# read_timeout_seconds: 10
|
|
63
|
+
# max_retries: 2
|
|
64
|
+
|
|
65
|
+
# Debug-only settings (optional, default false)
|
|
66
|
+
# debug:
|
|
67
|
+
# allow_console: false
|
|
51
68
|
|
|
52
69
|
# Advanced settings (optional)
|
|
53
70
|
# advanced:
|
|
@@ -118,343 +135,7 @@ Thumbs.db
|
|
|
118
135
|
|
|
119
136
|
INIT_JOBS_TEMPLATE = '"""Job definitions - auto-discovered from ./jobs."""\n'
|
|
120
137
|
|
|
121
|
-
AGENTS_TEMPLATE = """
|
|
122
|
-
# Agent Guide: Build a Compliant brawny Job
|
|
123
|
-
|
|
124
|
-
This file is meant for user agents that generate new job files. It is a fast, practical spec.
|
|
125
|
-
|
|
126
|
-
## Golden Rules
|
|
127
|
-
- Avoid over-engineering.
|
|
128
|
-
- Aim for simplicity and elegance.
|
|
129
|
-
|
|
130
|
-
## Job File Checklist (Minimal)
|
|
131
|
-
- Location: `jobs/<job_name>.py`
|
|
132
|
-
- Import `Job` and `job`.
|
|
133
|
-
- Add `@job` decorator (omit it to hide a WIP job from discovery/validation).
|
|
134
|
-
- Implement `check()` (sync or async).
|
|
135
|
-
- If it sends a transaction, implement `build_intent()` (sync).
|
|
136
|
-
|
|
137
|
-
## Required vs Optional Hooks
|
|
138
|
-
|
|
139
|
-
### Required
|
|
140
|
-
- `check(self) -> Trigger | None` OR `check(self, ctx) -> Trigger | None`
|
|
141
|
-
- Must return `trigger(...)` or `None`.
|
|
142
|
-
- **Implicit style** `def check(self):` - use API helpers (`block`, `kv`, `Contract`, `ctx()`)
|
|
143
|
-
- **Explicit style** `def check(self, ctx):` - ctx passed directly (param MUST be named 'ctx')
|
|
144
|
-
- Can be async: `async def check(self)` or `async def check(self, ctx)`
|
|
145
|
-
|
|
146
|
-
### Required only for tx jobs
|
|
147
|
-
- `build_intent(self, trigger) -> TxIntentSpec`
|
|
148
|
-
- Build calldata and return `intent(...)`.
|
|
149
|
-
- Only called if `trigger.tx_required` is True.
|
|
150
|
-
|
|
151
|
-
### Optional simulation hook
|
|
152
|
-
- `validate_simulation(self, output) -> bool`
|
|
153
|
-
- Return False to fail the intent after a successful simulation.
|
|
154
|
-
|
|
155
|
-
### Optional alert hooks
|
|
156
|
-
- `alert_triggered(self, ctx)` - Called when job triggers
|
|
157
|
-
- `alert_confirmed(self, ctx)` - Called after TX confirms (ctx.receipt available)
|
|
158
|
-
- `alert_failed(self, ctx)` - Called on failure (ctx.tx can be None for pre-broadcast failures)
|
|
159
|
-
- Return `str`, `(str, parse_mode)`, or `None`.
|
|
160
|
-
|
|
161
|
-
### Optional lifecycle hooks
|
|
162
|
-
- `on_success(self, ctx, receipt, intent, attempt)`
|
|
163
|
-
- `on_failure(self, ctx, error, intent, attempt)` # attempt can be None pre-broadcast
|
|
164
|
-
|
|
165
|
-
## Job Class Attributes
|
|
166
|
-
|
|
167
|
-
### Required (auto-derived if not set and @job is used)
|
|
168
|
-
- `job_id: str` - Stable identifier (must not change)
|
|
169
|
-
- `name: str` - Human-readable name for logs/alerts
|
|
170
|
-
|
|
171
|
-
### Optional scheduling
|
|
172
|
-
- `check_interval_blocks: int = 1` - Min blocks between check() calls
|
|
173
|
-
- `check_timeout_seconds: int = 30` - Timeout for check()
|
|
174
|
-
- `build_timeout_seconds: int = 10` - Timeout for build_intent()
|
|
175
|
-
- `max_in_flight_intents: int | None = None` - Cap on active intents
|
|
176
|
-
|
|
177
|
-
### Optional gas overrides (all values in wei)
|
|
178
|
-
- `max_fee: int | None = None` - Max fee cap for gating/txs (None = no gating)
|
|
179
|
-
- `priority_fee: int | None = None` - Tip override for this job
|
|
180
|
-
|
|
181
|
-
### Optional simulation
|
|
182
|
-
- `disable_simulation: bool = False` - Skip pre-broadcast simulation
|
|
183
|
-
- `rpc: str | None = None` - Override RPC for simulation
|
|
184
|
-
|
|
185
|
-
### Broadcast routing (via @job decorator)
|
|
186
|
-
Configure broadcast routing using the `@job` decorator:
|
|
187
|
-
```python
|
|
188
|
-
@job(job_id="arb_exec", rpc_group="flashbots", signer="hot1")
|
|
189
|
-
class ArbitrageExecutor(Job):
|
|
190
|
-
...
|
|
191
|
-
```
|
|
192
|
-
- `job_id` - Optional override (defaults to snake_case of class name)
|
|
193
|
-
- `rpc_group` - Name of RPC group for reads and broadcasts
|
|
194
|
-
- `broadcast_group` - Name of RPC group for broadcasts (default: uses rpc_default_group)
|
|
195
|
-
- `read_group` - Name of RPC group for read operations (default: uses rpc_default_group)
|
|
196
|
-
- `signer` - Name of signer alias (required for tx jobs)
|
|
197
|
-
|
|
198
|
-
Define RPC groups in config:
|
|
199
|
-
```yaml
|
|
200
|
-
rpc_groups:
|
|
201
|
-
primary:
|
|
202
|
-
endpoints:
|
|
203
|
-
- https://eth.llamarpc.com
|
|
204
|
-
private:
|
|
205
|
-
endpoints:
|
|
206
|
-
- https://rpc.flashbots.net
|
|
207
|
-
- https://relay.flashbots.net
|
|
208
|
-
rpc_default_group: primary
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
### Alert routing
|
|
212
|
-
- `@job(alert_to="ops")` - Route alerts to named chat defined in config
|
|
213
|
-
- `@job(alert_to=["ops", "dev"])` - Route to multiple chats
|
|
214
|
-
- Names must be defined in `telegram.chats` config section
|
|
215
|
-
- If not specified, uses `telegram.default` from config
|
|
216
|
-
|
|
217
|
-
## Core API (What to Use)
|
|
218
|
-
|
|
219
|
-
### Contract access (brownie-style)
|
|
220
|
-
```python
|
|
221
|
-
from brawny import Contract
|
|
222
|
-
vault = Contract(self.vault_address) # By address
|
|
223
|
-
decimals = vault.decimals() # View call
|
|
224
|
-
data = vault.harvest.encode_input() # Get calldata
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
### JSON interfaces (brownie-style)
|
|
229
|
-
Place ABI JSON files in `./interfaces`, then:
|
|
230
|
-
```python
|
|
231
|
-
from brawny import interface
|
|
232
|
-
token = interface.IERC20("0x1234...")
|
|
233
|
-
balance = token.balanceOf("0xabc...")
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
### Job hook helpers (implicit context)
|
|
237
|
-
```python
|
|
238
|
-
from brawny import trigger, intent, block, gas_ok
|
|
239
|
-
return trigger(reason="...", data={...}, idempotency_parts=[block.number])
|
|
240
|
-
return intent(signer_address="worker", to_address=addr, data=calldata)
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
### Event access in alert hooks (brownie-compatible)
|
|
244
|
-
```python
|
|
245
|
-
def alert_confirmed(self, ctx):
|
|
246
|
-
deposit = ctx.events["Deposit"][0] # First Deposit event
|
|
247
|
-
amount = deposit["amount"] # Field access
|
|
248
|
-
if "Deposit" in ctx.events: # Check if event exists
|
|
249
|
-
...
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
### Other context access
|
|
253
|
-
- `ctx()` - Get full CheckContext/BuildContext when using implicit style
|
|
254
|
-
- `block.number`, `block.timestamp` - Current block info
|
|
255
|
-
- `rpc.*` - RPC manager proxy (e.g., `rpc.get_gas_price()`)
|
|
256
|
-
- `gas_ok()` - Check if current gas is below job's max_fee (async)
|
|
257
|
-
- `gas_quote()` - Get current base_fee (async)
|
|
258
|
-
- `kv.get(key, default=None)`, `kv.set(key, value)` - Persistent KV store (import from brawny)
|
|
259
|
-
|
|
260
|
-
### Accounts
|
|
261
|
-
- Use `intent(signer_address=...)` with a signer alias or address.
|
|
262
|
-
- If you set `@job(signer="alias")`, use `self.signer` (alias) or `self.signer_address` (resolved address).
|
|
263
|
-
- The signer alias must exist in the accounts directory (`~/.brawny/accounts`).
|
|
264
|
-
|
|
265
|
-
## Example: Transaction Job
|
|
266
|
-
|
|
267
|
-
```python
|
|
268
|
-
from brawny import Job, job, Contract, trigger, intent, block
|
|
269
|
-
|
|
270
|
-
@job(signer="worker")
|
|
271
|
-
class MyKeeperJob(Job):
|
|
272
|
-
job_id = "my_keeper"
|
|
273
|
-
name = "My Keeper"
|
|
274
|
-
check_interval_blocks = 1
|
|
275
|
-
keeper_address = "0x..."
|
|
276
|
-
|
|
277
|
-
def check(self, ctx):
|
|
278
|
-
keeper = Contract(self.keeper_address)
|
|
279
|
-
if keeper.canWork():
|
|
280
|
-
return trigger(
|
|
281
|
-
reason="Keeper can work",
|
|
282
|
-
idempotency_parts=[block.number],
|
|
283
|
-
)
|
|
284
|
-
return None
|
|
285
|
-
|
|
286
|
-
def build_intent(self, trig):
|
|
287
|
-
keeper = Contract(self.keeper_address)
|
|
288
|
-
return intent(
|
|
289
|
-
signer_address=self.signer,
|
|
290
|
-
to_address=self.keeper_address,
|
|
291
|
-
data=keeper.work.encode_input(),
|
|
292
|
-
)
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
## Example: Job with Custom Broadcast and Alerts
|
|
296
|
-
|
|
297
|
-
```python
|
|
298
|
-
from brawny import Job, Contract, trigger, intent, explorer_link
|
|
299
|
-
from brawny.jobs.registry import job
|
|
300
|
-
|
|
301
|
-
@job(rpc_group="flashbots", signer="treasury-signer", alert_to="private_ops")
|
|
302
|
-
class TreasuryJob(Job):
|
|
303
|
-
\"\"\"Critical treasury operations with dedicated RPC and private alerts.\"\"\"
|
|
304
|
-
|
|
305
|
-
name = "Treasury Operations"
|
|
306
|
-
check_interval_blocks = 1
|
|
307
|
-
treasury_address = "0x..."
|
|
308
|
-
|
|
309
|
-
def check(self, ctx):
|
|
310
|
-
treasury = Contract(self.treasury_address)
|
|
311
|
-
if treasury.needsRebalance():
|
|
312
|
-
return trigger(reason="Treasury needs rebalancing")
|
|
313
|
-
return None
|
|
314
|
-
|
|
315
|
-
def build_intent(self, trig):
|
|
316
|
-
treasury = Contract(self.treasury_address)
|
|
317
|
-
return intent(
|
|
318
|
-
signer_address=self.signer,
|
|
319
|
-
to_address=self.treasury_address,
|
|
320
|
-
data=treasury.rebalance.encode_input(),
|
|
321
|
-
)
|
|
322
|
-
|
|
323
|
-
def alert_confirmed(self, ctx):
|
|
324
|
-
if not ctx.tx:
|
|
325
|
-
return None
|
|
326
|
-
return f"Treasury rebalanced: {explorer_link(ctx.tx.hash)}"
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
## Example: Monitor-Only Job (Implicit Context Style)
|
|
330
|
-
|
|
331
|
-
```python
|
|
332
|
-
from brawny import Job, job, Contract, trigger, kv
|
|
333
|
-
|
|
334
|
-
@job
|
|
335
|
-
class MonitorJob(Job):
|
|
336
|
-
job_id = "monitor"
|
|
337
|
-
name = "Monitor"
|
|
338
|
-
|
|
339
|
-
def check(self): # No ctx param - uses implicit context
|
|
340
|
-
value = Contract("0x...").value()
|
|
341
|
-
last = kv.get("last", 0)
|
|
342
|
-
if value > last:
|
|
343
|
-
kv.set("last", value)
|
|
344
|
-
return trigger(
|
|
345
|
-
reason="Value increased",
|
|
346
|
-
data={"value": value},
|
|
347
|
-
tx_required=False,
|
|
348
|
-
)
|
|
349
|
-
return None
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
## Natural-Language -> Job Translation Guide
|
|
353
|
-
|
|
354
|
-
When a user says:
|
|
355
|
-
- **"Check X every block"** -> `check_interval_blocks = 1`
|
|
356
|
-
- **"Only run if gas below Y"** -> set `max_fee` (wei) and use `await gas_ok()` in async check()
|
|
357
|
-
- **"Use signer Z"** -> `@job(signer="Z")` and use `self.signer` in `intent(...)`
|
|
358
|
-
- **"Alert on success/failure"** -> implement `alert_confirmed` / `alert_failed`
|
|
359
|
-
- **"Remember last value"** -> use `kv.get/set` (import from brawny)
|
|
360
|
-
- **"Use Flashbots"** -> `@job(rpc_group="flashbots")` with flashbots group in config
|
|
361
|
-
- **"Send alerts to private channel"** -> `@job(alert_to="private_ops")` with chat in config
|
|
362
|
-
|
|
363
|
-
## Failure Modes
|
|
364
|
-
|
|
365
|
-
The `alert_failed` hook provides rich context about what failed and when.
|
|
366
|
-
|
|
367
|
-
### Failure Classification
|
|
368
|
-
|
|
369
|
-
**FailureType** (what failed):
|
|
370
|
-
- `SIMULATION_REVERTED` - TX would revert on-chain (permanent)
|
|
371
|
-
- `SIMULATION_NETWORK_ERROR` - RPC error during simulation (transient)
|
|
372
|
-
- `DEADLINE_EXPIRED` - Intent took too long (permanent)
|
|
373
|
-
- `SIGNER_FAILED` - Keystore/signer issue
|
|
374
|
-
- `NONCE_FAILED` - Couldn't reserve nonce
|
|
375
|
-
- `SIGN_FAILED` - Signing error
|
|
376
|
-
- `BROADCAST_FAILED` - RPC rejected transaction (transient)
|
|
377
|
-
- `TX_REVERTED` - On-chain revert (permanent)
|
|
378
|
-
- `NONCE_CONSUMED` - Nonce used by another transaction
|
|
379
|
-
- `CHECK_EXCEPTION` - job.check() raised an exception
|
|
380
|
-
- `BUILD_TX_EXCEPTION` - job.build_tx() raised an exception
|
|
381
|
-
- `UNKNOWN` - Fallback for unexpected failures
|
|
382
|
-
|
|
383
|
-
**FailureStage** (when it failed):
|
|
384
|
-
- `PRE_BROADCAST` - Failed before reaching the chain
|
|
385
|
-
- `BROADCAST` - Failed during broadcast
|
|
386
|
-
- `POST_BROADCAST` - Failed after broadcast (on-chain)
|
|
387
|
-
|
|
388
|
-
### AlertContext in alert_failed
|
|
389
|
-
|
|
390
|
-
```python
|
|
391
|
-
# AlertContext fields (all hooks)
|
|
392
|
-
ctx.job # JobMetadata (id, name)
|
|
393
|
-
ctx.trigger # Trigger that initiated this flow
|
|
394
|
-
ctx.chain_id # Chain ID
|
|
395
|
-
ctx.hook # HookType enum (TRIGGERED, CONFIRMED, FAILED)
|
|
396
|
-
ctx.tx # TxInfo | None (hash, nonce, gas params)
|
|
397
|
-
ctx.receipt # TxReceipt | None (only in alert_confirmed)
|
|
398
|
-
ctx.block # BlockInfo | None
|
|
399
|
-
ctx.error_info # ErrorInfo | None (structured, JSON-safe)
|
|
400
|
-
ctx.failure_type # FailureType | None
|
|
401
|
-
ctx.failure_stage # FailureStage | None
|
|
402
|
-
ctx.events # EventDict (only in alert_confirmed)
|
|
403
|
-
|
|
404
|
-
# AlertContext properties
|
|
405
|
-
ctx.is_permanent_failure # True if retrying won't help
|
|
406
|
-
ctx.is_transient_failure # True if failure might resolve on retry
|
|
407
|
-
ctx.error_message # Convenience: error_info.message or "unknown"
|
|
408
|
-
|
|
409
|
-
# AlertContext methods
|
|
410
|
-
ctx.explorer_link(hash) # "[🔗 View](url)" markdown link
|
|
411
|
-
ctx.shorten(hex_str) # "0x1234...abcd"
|
|
412
|
-
ctx.has_receipt() # True if receipt available
|
|
413
|
-
ctx.has_error() # True if error_info available
|
|
414
|
-
```
|
|
415
|
-
|
|
416
|
-
### Example: Handling Failures
|
|
417
|
-
|
|
418
|
-
```python
|
|
419
|
-
from brawny import Job, job
|
|
420
|
-
from brawny.model.errors import FailureType
|
|
421
|
-
|
|
422
|
-
@job
|
|
423
|
-
class RobustJob(Job):
|
|
424
|
-
job_id = "robust_job"
|
|
425
|
-
name = "Robust Job"
|
|
426
|
-
|
|
427
|
-
def alert_failed(self, ctx):
|
|
428
|
-
# Suppress alerts for transient failures
|
|
429
|
-
if ctx.is_transient_failure:
|
|
430
|
-
return None # No alert
|
|
431
|
-
|
|
432
|
-
# Detailed message for permanent failures
|
|
433
|
-
if ctx.failure_type == FailureType.SIMULATION_REVERTED:
|
|
434
|
-
return f"TX would revert: {ctx.error_message}"
|
|
435
|
-
elif ctx.failure_type == FailureType.TX_REVERTED:
|
|
436
|
-
if not ctx.tx:
|
|
437
|
-
return f"TX reverted on-chain: {ctx.error_message}"
|
|
438
|
-
return f"TX reverted on-chain: {ctx.explorer_link(ctx.tx.hash)}"
|
|
439
|
-
elif ctx.failure_type == FailureType.NONCE_CONSUMED:
|
|
440
|
-
return "Nonce conflict! Check signer activity."
|
|
441
|
-
elif ctx.failure_type == FailureType.CHECK_EXCEPTION:
|
|
442
|
-
return f"check() crashed: {ctx.error_message}"
|
|
443
|
-
elif ctx.failure_type == FailureType.BUILD_TX_EXCEPTION:
|
|
444
|
-
return f"build_intent() crashed: {ctx.error_message}"
|
|
445
|
-
else:
|
|
446
|
-
return f"Failed ({ctx.failure_type.value}): {ctx.error_message}"
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
## Required Output from Agent
|
|
450
|
-
When generating a new job file, the agent must provide:
|
|
451
|
-
- File path
|
|
452
|
-
- Job class name
|
|
453
|
-
- `job_id` and `name`
|
|
454
|
-
- `check()` implementation
|
|
455
|
-
- `build_intent()` if tx required
|
|
456
|
-
- Any alert hooks requested
|
|
457
|
-
"""
|
|
138
|
+
AGENTS_TEMPLATE = resources.files("brawny").joinpath("assets/AGENTS.md").read_text(encoding="utf-8")
|
|
458
139
|
|
|
459
140
|
EXAMPLES_TEMPLATE = '''\
|
|
460
141
|
"""Example job patterns - NOT registered.
|
|
@@ -481,7 +162,7 @@ class MonitorOnlyJob(Job):
|
|
|
481
162
|
|
|
482
163
|
Outcome:
|
|
483
164
|
- Creates: Trigger only (no intent, no transaction)
|
|
484
|
-
- Alerts:
|
|
165
|
+
- Alerts: on_trigger only
|
|
485
166
|
"""
|
|
486
167
|
|
|
487
168
|
job_id = "monitor_example"
|
|
@@ -519,17 +200,12 @@ class MonitorOnlyJob(Job):
|
|
|
519
200
|
kv.set("last_price", price)
|
|
520
201
|
return None
|
|
521
202
|
|
|
522
|
-
def
|
|
523
|
-
"""
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
Tuple of (message, parse_mode) or string
|
|
527
|
-
"""
|
|
528
|
-
data = {}
|
|
529
|
-
return (
|
|
203
|
+
def on_trigger(self, ctx):
|
|
204
|
+
"""Send alert when monitor triggers."""
|
|
205
|
+
data = ctx.trigger.data
|
|
206
|
+
ctx.alert(
|
|
530
207
|
f"Price alert: {data['old_price']:.2f} -> {data['new_price']:.2f}\\n"
|
|
531
|
-
f"Change: {data['change_percent']:.2f}%"
|
|
532
|
-
"Markdown",
|
|
208
|
+
f"Change: {data['change_percent']:.2f}%"
|
|
533
209
|
)
|
|
534
210
|
'''
|
|
535
211
|
|