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
@@ -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(address_or_alias: str, yes: bool, config_path: str | None) -> None:
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
- - Query current chain pending nonce
54
- - Reset local next_nonce to match chain
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 RPCManager
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
- db = create_database(
78
- config.database_url,
79
- pool_size=config.database_pool_size,
80
- pool_max_overflow=config.database_pool_max_overflow,
81
- pool_timeout=config.database_pool_timeout_seconds,
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 = RPCManager.from_config(config)
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 >= chain_pending and r.status in (NonceStatus.RESERVED, NonceStatus.IN_FLIGHT)
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(address)
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 RPCManager
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
- db = create_database(
169
- config.database_url,
170
- pool_size=config.database_pool_size,
171
- pool_max_overflow=config.database_pool_max_overflow,
172
- pool_timeout=config.database_pool_timeout_seconds,
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 = RPCManager.from_config(config)
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. Use PostgreSQL for multi-worker setups.
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: alert_triggered only
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 alert_triggered(self, ctx):
523
- """Format alert message.
524
-
525
- Returns:
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