brawny 0.1.13__py3-none-any.whl → 0.1.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. brawny/__init__.py +2 -0
  2. brawny/_context.py +5 -5
  3. brawny/_rpc/__init__.py +36 -12
  4. brawny/_rpc/broadcast.py +14 -13
  5. brawny/_rpc/caller.py +243 -0
  6. brawny/_rpc/client.py +539 -0
  7. brawny/_rpc/clients.py +11 -11
  8. brawny/_rpc/context.py +23 -0
  9. brawny/_rpc/errors.py +465 -31
  10. brawny/_rpc/gas.py +7 -6
  11. brawny/_rpc/pool.py +18 -0
  12. brawny/_rpc/retry.py +266 -0
  13. brawny/_rpc/retry_policy.py +81 -0
  14. brawny/accounts.py +28 -9
  15. brawny/alerts/__init__.py +15 -18
  16. brawny/alerts/abi_resolver.py +212 -36
  17. brawny/alerts/base.py +2 -2
  18. brawny/alerts/contracts.py +77 -10
  19. brawny/alerts/errors.py +30 -3
  20. brawny/alerts/events.py +38 -5
  21. brawny/alerts/health.py +19 -13
  22. brawny/alerts/send.py +513 -55
  23. brawny/api.py +39 -11
  24. brawny/assets/AGENTS.md +325 -0
  25. brawny/async_runtime.py +48 -0
  26. brawny/chain.py +3 -3
  27. brawny/cli/commands/__init__.py +2 -0
  28. brawny/cli/commands/console.py +69 -19
  29. brawny/cli/commands/contract.py +2 -2
  30. brawny/cli/commands/controls.py +121 -0
  31. brawny/cli/commands/health.py +2 -2
  32. brawny/cli/commands/job_dev.py +6 -5
  33. brawny/cli/commands/jobs.py +99 -2
  34. brawny/cli/commands/maintenance.py +13 -29
  35. brawny/cli/commands/migrate.py +1 -0
  36. brawny/cli/commands/run.py +10 -3
  37. brawny/cli/commands/script.py +8 -3
  38. brawny/cli/commands/signer.py +143 -26
  39. brawny/cli/helpers.py +0 -3
  40. brawny/cli_templates.py +25 -349
  41. brawny/config/__init__.py +4 -1
  42. brawny/config/models.py +43 -57
  43. brawny/config/parser.py +268 -57
  44. brawny/config/validation.py +52 -15
  45. brawny/daemon/context.py +4 -2
  46. brawny/daemon/core.py +185 -63
  47. brawny/daemon/loops.py +166 -98
  48. brawny/daemon/supervisor.py +261 -0
  49. brawny/db/__init__.py +14 -26
  50. brawny/db/base.py +248 -151
  51. brawny/db/global_cache.py +11 -1
  52. brawny/db/migrate.py +175 -28
  53. brawny/db/migrations/001_init.sql +4 -3
  54. brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
  55. brawny/db/migrations/011_add_job_logs.sql +1 -2
  56. brawny/db/migrations/012_add_claimed_by.sql +2 -2
  57. brawny/db/migrations/013_attempt_unique.sql +10 -0
  58. brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
  59. brawny/db/migrations/015_add_signer_alias.sql +14 -0
  60. brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
  61. brawny/db/migrations/017_add_job_drain.sql +6 -0
  62. brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
  63. brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
  64. brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
  65. brawny/db/ops/__init__.py +3 -25
  66. brawny/db/ops/logs.py +1 -2
  67. brawny/db/queries.py +47 -91
  68. brawny/db/serialized.py +65 -0
  69. brawny/db/sqlite/__init__.py +1001 -0
  70. brawny/db/sqlite/connection.py +231 -0
  71. brawny/db/sqlite/execute.py +116 -0
  72. brawny/db/sqlite/mappers.py +190 -0
  73. brawny/db/sqlite/repos/attempts.py +372 -0
  74. brawny/db/sqlite/repos/block_state.py +102 -0
  75. brawny/db/sqlite/repos/cache.py +104 -0
  76. brawny/db/sqlite/repos/intents.py +1021 -0
  77. brawny/db/sqlite/repos/jobs.py +200 -0
  78. brawny/db/sqlite/repos/maintenance.py +182 -0
  79. brawny/db/sqlite/repos/signers_nonces.py +566 -0
  80. brawny/db/sqlite/tx.py +119 -0
  81. brawny/http.py +194 -0
  82. brawny/invariants.py +11 -24
  83. brawny/jobs/base.py +8 -0
  84. brawny/jobs/job_validation.py +2 -1
  85. brawny/keystore.py +83 -7
  86. brawny/lifecycle.py +64 -12
  87. brawny/logging.py +0 -2
  88. brawny/metrics.py +84 -12
  89. brawny/model/contexts.py +111 -9
  90. brawny/model/enums.py +1 -0
  91. brawny/model/errors.py +18 -0
  92. brawny/model/types.py +47 -131
  93. brawny/network_guard.py +133 -0
  94. brawny/networks/__init__.py +5 -5
  95. brawny/networks/config.py +1 -7
  96. brawny/networks/manager.py +14 -11
  97. brawny/runtime_controls.py +74 -0
  98. brawny/scheduler/poller.py +11 -7
  99. brawny/scheduler/reorg.py +95 -39
  100. brawny/scheduler/runner.py +442 -168
  101. brawny/scheduler/shutdown.py +3 -3
  102. brawny/script_tx.py +3 -3
  103. brawny/telegram.py +53 -7
  104. brawny/testing.py +1 -0
  105. brawny/timeout.py +38 -0
  106. brawny/tx/executor.py +922 -308
  107. brawny/tx/intent.py +54 -16
  108. brawny/tx/monitor.py +31 -12
  109. brawny/tx/nonce.py +212 -90
  110. brawny/tx/replacement.py +69 -18
  111. brawny/tx/retry_policy.py +24 -0
  112. brawny/tx/stages/types.py +75 -0
  113. brawny/types.py +18 -0
  114. brawny/utils.py +41 -0
  115. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
  116. brawny-0.1.22.dist-info/RECORD +163 -0
  117. brawny/_rpc/manager.py +0 -982
  118. brawny/_rpc/selector.py +0 -156
  119. brawny/db/base_new.py +0 -165
  120. brawny/db/mappers.py +0 -182
  121. brawny/db/migrations/008_add_transactions.sql +0 -72
  122. brawny/db/ops/attempts.py +0 -108
  123. brawny/db/ops/blocks.py +0 -83
  124. brawny/db/ops/cache.py +0 -93
  125. brawny/db/ops/intents.py +0 -296
  126. brawny/db/ops/jobs.py +0 -110
  127. brawny/db/ops/nonces.py +0 -322
  128. brawny/db/postgres.py +0 -2535
  129. brawny/db/postgres_new.py +0 -196
  130. brawny/db/sqlite.py +0 -2733
  131. brawny/db/sqlite_new.py +0 -191
  132. brawny-0.1.13.dist-info/RECORD +0 -141
  133. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
  134. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
  135. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/top_level.txt +0 -0
brawny/config/__init__.py CHANGED
@@ -8,10 +8,11 @@ from __future__ import annotations
8
8
 
9
9
  import os
10
10
 
11
- from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig
11
+ from brawny.config.models import AdvancedConfig, Config, IntentCooldownConfig, RPCGroupConfig
12
12
  from brawny.config.validation import (
13
13
  InvalidEndpointError,
14
14
  canonicalize_endpoint,
15
+ canonicalize_endpoints,
15
16
  dedupe_preserve_order,
16
17
  validate_config,
17
18
  )
@@ -21,10 +22,12 @@ __all__ = [
21
22
  # Models
22
23
  "Config",
23
24
  "AdvancedConfig",
25
+ "IntentCooldownConfig",
24
26
  "RPCGroupConfig",
25
27
  # Validation
26
28
  "InvalidEndpointError",
27
29
  "canonicalize_endpoint",
30
+ "canonicalize_endpoints",
28
31
  "dedupe_preserve_order",
29
32
  "validate_config",
30
33
  # Errors
brawny/config/models.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  from dataclasses import asdict, dataclass, field
9
9
  from brawny.model.enums import KeystoreType
10
+ from brawny.http import HttpConfig
10
11
 
11
12
  DEFAULT_BLOCK_HASH_HISTORY_SIZE = 256
12
13
  DEFAULT_JOB_ERROR_BACKOFF_BLOCKS = 1
@@ -15,17 +16,15 @@ DEFAULT_NONCE_RECONCILE_INTERVAL_SECONDS = 300
15
16
  DEFAULT_STUCK_TX_BLOCKS = 50
16
17
  DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 30
17
18
  DEFAULT_RPC_RETRY_BACKOFF_BASE = 1.0
18
- DEFAULT_RPC_CIRCUIT_BREAKER_SECONDS = 300
19
19
  DEFAULT_DB_CIRCUIT_BREAKER_FAILURES = 5
20
20
  DEFAULT_DB_CIRCUIT_BREAKER_SECONDS = 30
21
21
  DEFAULT_GAS_REFRESH_SECONDS = 15
22
22
  DEFAULT_FALLBACK_GAS_LIMIT = 500_000
23
- DEFAULT_TELEGRAM_RATE_LIMIT_PER_MINUTE = 20
24
23
  DEFAULT_ABI_CACHE_TTL_SECONDS = 86400 * 7
25
- DEFAULT_DATABASE_POOL_TIMEOUT_SECONDS = 30.0
26
24
  DEFAULT_NONCE_GAP_ALERT_SECONDS = 300
27
25
  DEFAULT_MAX_EXECUTOR_RETRIES = 5
28
26
  DEFAULT_FINALITY_CONFIRMATIONS = 12
27
+ DEFAULT_ALERTS_HEALTH_MAX_OLDEST_AGE_SECONDS = 120
29
28
 
30
29
 
31
30
  @dataclass
@@ -36,7 +35,7 @@ class TelegramConfig:
36
35
  bot_token: Bot token for API calls (None = disabled)
37
36
  chats: Named chat targets (e.g., {"ops": "-100...", "dev": "-100..."})
38
37
  default: Default targets when job.alert_to not specified
39
- parse_mode: Default parse mode for telegram messages (None = plain text)
38
+ parse_mode: Default parse mode for telegram messages (None = disable formatting)
40
39
  health_chat: Chat name for daemon health alerts (None = logged only)
41
40
  health_cooldown_seconds: Deduplication window for health alerts
42
41
  """
@@ -49,6 +48,31 @@ class TelegramConfig:
49
48
  health_cooldown_seconds: int = 1800 # 30 minutes between identical alerts
50
49
 
51
50
 
51
+ @dataclass
52
+ class GuardrailsConfig:
53
+ """Guardrail configuration for linting and runtime enforcement."""
54
+
55
+ lint_paths: list[str] = field(default_factory=list)
56
+
57
+
58
+ @dataclass
59
+ class DebugConfig:
60
+ """Debug-only settings (opt-in)."""
61
+
62
+ allow_console: bool = False
63
+ enable_null_lease_reclaim: bool = False
64
+
65
+
66
+ @dataclass
67
+ class IntentCooldownConfig:
68
+ """Global intent cooldown configuration."""
69
+
70
+ enabled: bool = True
71
+ default_seconds: int = 300
72
+ max_seconds: int = 3600
73
+ prune_older_than_days: int = 30
74
+
75
+
52
76
  @dataclass
53
77
  class RPCGroupConfig:
54
78
  """A named collection of RPC endpoints."""
@@ -88,13 +112,12 @@ class AdvancedConfig:
88
112
  rpc_timeout_seconds: float = 30.0
89
113
  rpc_max_retries: int = 3
90
114
 
91
- # Database pool (Postgres only)
92
- database_pool_size: int = 5
93
- database_pool_max_overflow: int = 10
94
-
95
115
  # Job logs
96
116
  log_retention_days: int = 7
97
117
 
118
+ # Alerts
119
+ alerts_health_max_oldest_age_seconds: int = DEFAULT_ALERTS_HEALTH_MAX_OLDEST_AGE_SECONDS
120
+
98
121
 
99
122
  @dataclass
100
123
  class Config:
@@ -126,6 +149,18 @@ class Config:
126
149
  # Telegram (canonical form - parsed from telegram: or legacy fields)
127
150
  telegram: TelegramConfig = field(default_factory=TelegramConfig)
128
151
 
152
+ # Guardrails (lint hints for job code)
153
+ guardrails: GuardrailsConfig = field(default_factory=GuardrailsConfig)
154
+
155
+ # Debug-only flags (disabled by default)
156
+ debug: DebugConfig = field(default_factory=DebugConfig)
157
+
158
+ # Intent cooldown (global defaults)
159
+ intent_cooldown: IntentCooldownConfig = field(default_factory=IntentCooldownConfig)
160
+
161
+ # HTTP (approved client for job code)
162
+ http: HttpConfig = field(default_factory=HttpConfig)
163
+
129
164
  # Metrics
130
165
  metrics_port: int = 9091
131
166
 
@@ -145,15 +180,6 @@ class Config:
145
180
  validate_advanced_config(self._advanced_or_default())
146
181
 
147
182
  @property
148
- def is_sqlite(self) -> bool:
149
- """Check if using SQLite database."""
150
- return self.database_url.startswith("sqlite:///")
151
-
152
- @property
153
- def is_postgres(self) -> bool:
154
- """Check if using PostgreSQL database."""
155
- return self.database_url.startswith(("postgresql://", "postgres://"))
156
-
157
183
  @classmethod
158
184
  def from_env(cls) -> "Config":
159
185
  """Load configuration from environment variables."""
@@ -209,10 +235,6 @@ class Config:
209
235
  }
210
236
  redacted_groups[group_name] = group_value
211
237
  redacted[key] = redacted_groups
212
- elif key == "rpc_rate_limits" and isinstance(value, dict):
213
- redacted[key] = {
214
- _redact_url(str(endpoint)): limiter for endpoint, limiter in value.items()
215
- }
216
238
  elif key == "telegram" and isinstance(value, dict):
217
239
  # Redact bot_token within telegram config
218
240
  redacted[key] = {
@@ -280,14 +302,6 @@ class Config:
280
302
  def rpc_max_retries(self) -> int:
281
303
  return self._advanced_or_default().rpc_max_retries
282
304
 
283
- @property
284
- def database_pool_size(self) -> int:
285
- return self._advanced_or_default().database_pool_size
286
-
287
- @property
288
- def database_pool_max_overflow(self) -> int:
289
- return self._advanced_or_default().database_pool_max_overflow
290
-
291
305
  @property
292
306
  def priority_fee(self) -> int:
293
307
  return int(self.default_priority_fee_gwei * 1_000_000_000)
@@ -343,10 +357,6 @@ class Config:
343
357
  def rpc_retry_backoff_base(self) -> float:
344
358
  return DEFAULT_RPC_RETRY_BACKOFF_BASE
345
359
 
346
- @property
347
- def rpc_circuit_breaker_seconds(self) -> int:
348
- return DEFAULT_RPC_CIRCUIT_BREAKER_SECONDS
349
-
350
360
  @property
351
361
  def db_circuit_breaker_failures(self) -> int:
352
362
  return DEFAULT_DB_CIRCUIT_BREAKER_FAILURES
@@ -355,18 +365,6 @@ class Config:
355
365
  def db_circuit_breaker_seconds(self) -> int:
356
366
  return DEFAULT_DB_CIRCUIT_BREAKER_SECONDS
357
367
 
358
- @property
359
- def rpc_rate_limit_per_second(self) -> float | None:
360
- return None
361
-
362
- @property
363
- def rpc_rate_limit_burst(self) -> int | None:
364
- return None
365
-
366
- @property
367
- def rpc_rate_limits(self) -> dict[str, dict[str, float | int]]:
368
- return {}
369
-
370
368
  @property
371
369
  def gas_refresh_seconds(self) -> int:
372
370
  return DEFAULT_GAS_REFRESH_SECONDS
@@ -375,18 +373,10 @@ class Config:
375
373
  def fallback_gas_limit(self) -> int:
376
374
  return DEFAULT_FALLBACK_GAS_LIMIT
377
375
 
378
- @property
379
- def telegram_rate_limit_per_minute(self) -> int:
380
- return DEFAULT_TELEGRAM_RATE_LIMIT_PER_MINUTE
381
-
382
376
  @property
383
377
  def abi_cache_ttl_seconds(self) -> int:
384
378
  return DEFAULT_ABI_CACHE_TTL_SECONDS
385
379
 
386
- @property
387
- def database_pool_timeout_seconds(self) -> float:
388
- return DEFAULT_DATABASE_POOL_TIMEOUT_SECONDS
389
-
390
380
  @property
391
381
  def allow_unsafe_nonce_reset(self) -> bool:
392
382
  return False
@@ -395,10 +385,6 @@ class Config:
395
385
  def nonce_gap_alert_seconds(self) -> int:
396
386
  return DEFAULT_NONCE_GAP_ALERT_SECONDS
397
387
 
398
- @property
399
- def brownie_password_fallback(self) -> bool:
400
- return False
401
-
402
388
  @property
403
389
  def log_retention_days(self) -> int:
404
390
  return self._advanced_or_default().log_retention_days
brawny/config/parser.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import os
9
9
  import re
10
+ from typing import Callable
10
11
  from dataclasses import replace
11
12
 
12
13
  from brawny.model.enums import KeystoreType
@@ -33,8 +34,6 @@ _ADVANCED_FIELDS = {
33
34
  "fee_bump_percent",
34
35
  "rpc_timeout_seconds",
35
36
  "rpc_max_retries",
36
- "database_pool_size",
37
- "database_pool_max_overflow",
38
37
  }
39
38
 
40
39
  _REMOVED_ENV_KEYS = {
@@ -104,6 +103,7 @@ def _parse_telegram(raw: dict) -> "TelegramConfig":
104
103
  default = [default]
105
104
  default = [d.strip() for d in default if d and str(d).strip()]
106
105
 
106
+ parse_mode_provided = "parse_mode" in tg
107
107
  parse_mode = tg.get("parse_mode")
108
108
  if isinstance(parse_mode, str):
109
109
  parse_mode = parse_mode.strip() or None
@@ -111,6 +111,8 @@ def _parse_telegram(raw: dict) -> "TelegramConfig":
111
111
  raise ConfigError(
112
112
  "telegram.parse_mode must be one of: Markdown, MarkdownV2, HTML, or null"
113
113
  )
114
+ if not parse_mode_provided and parse_mode is None:
115
+ parse_mode = TelegramConfig().parse_mode
114
116
 
115
117
  health_chat = tg.get("health_chat")
116
118
  if isinstance(health_chat, str):
@@ -160,6 +162,115 @@ def _parse_telegram(raw: dict) -> "TelegramConfig":
160
162
  return TelegramConfig()
161
163
 
162
164
 
165
+ def _parse_guardrails(raw: dict) -> "GuardrailsConfig":
166
+ from brawny.config.models import GuardrailsConfig
167
+
168
+ if "guardrails" in raw and isinstance(raw["guardrails"], dict):
169
+ guardrails = raw["guardrails"]
170
+ lint_paths = guardrails.get("lint_paths", [])
171
+ if lint_paths is None:
172
+ lint_paths = []
173
+ if isinstance(lint_paths, str):
174
+ lint_paths = [lint_paths]
175
+ lint_paths = [str(path).strip() for path in lint_paths if str(path).strip()]
176
+ return GuardrailsConfig(lint_paths=lint_paths)
177
+ return GuardrailsConfig()
178
+
179
+
180
+ def _parse_debug(raw: dict) -> "DebugConfig":
181
+ from brawny.config.models import DebugConfig
182
+
183
+ if "debug" in raw and isinstance(raw["debug"], dict):
184
+ debug = raw["debug"]
185
+ allow_console = debug.get("allow_console", False)
186
+ if not isinstance(allow_console, bool):
187
+ raise ConfigError("debug.allow_console must be a boolean")
188
+ enable_null_lease_reclaim = debug.get("enable_null_lease_reclaim", False)
189
+ if not isinstance(enable_null_lease_reclaim, bool):
190
+ raise ConfigError("debug.enable_null_lease_reclaim must be a boolean")
191
+ return DebugConfig(
192
+ allow_console=allow_console,
193
+ enable_null_lease_reclaim=enable_null_lease_reclaim,
194
+ )
195
+ return DebugConfig()
196
+
197
+
198
+ def _parse_intent_cooldown(raw: dict) -> "IntentCooldownConfig":
199
+ from brawny.config.models import IntentCooldownConfig
200
+
201
+ if "intent_cooldown" in raw and isinstance(raw["intent_cooldown"], dict):
202
+ cfg = raw["intent_cooldown"]
203
+ enabled = cfg.get("enabled", True)
204
+ default_seconds = cfg.get("default_seconds", IntentCooldownConfig().default_seconds)
205
+ max_seconds = cfg.get("max_seconds", IntentCooldownConfig().max_seconds)
206
+ prune_older_than_days = cfg.get(
207
+ "prune_older_than_days", IntentCooldownConfig().prune_older_than_days
208
+ )
209
+ if not isinstance(enabled, bool):
210
+ raise ConfigError("intent_cooldown.enabled must be a boolean")
211
+ return IntentCooldownConfig(
212
+ enabled=enabled,
213
+ default_seconds=int(default_seconds),
214
+ max_seconds=int(max_seconds),
215
+ prune_older_than_days=int(prune_older_than_days),
216
+ )
217
+ return IntentCooldownConfig()
218
+
219
+
220
+ def _parse_http(raw: dict) -> "HttpConfig":
221
+ """Parse HTTP config, handling canonical http: block."""
222
+ from brawny.http import HttpConfig
223
+
224
+ if "http" in raw and isinstance(raw["http"], dict):
225
+ cfg = raw["http"]
226
+ allowed_domains = cfg.get("allowed_domains", [])
227
+ if isinstance(allowed_domains, str):
228
+ allowed_domains = [allowed_domains]
229
+ if not isinstance(allowed_domains, list):
230
+ raise ConfigError("http.allowed_domains must be a list of domains")
231
+ allowed_domains = [str(d).strip() for d in allowed_domains if str(d).strip()]
232
+
233
+ connect_timeout_seconds = cfg.get("connect_timeout_seconds")
234
+ read_timeout_seconds = cfg.get("read_timeout_seconds")
235
+ max_retries = cfg.get("max_retries")
236
+ backoff_base_seconds = cfg.get("backoff_base_seconds")
237
+
238
+ if connect_timeout_seconds is None:
239
+ connect_timeout_seconds = HttpConfig().connect_timeout_seconds
240
+ if read_timeout_seconds is None:
241
+ read_timeout_seconds = HttpConfig().read_timeout_seconds
242
+ if max_retries is None:
243
+ max_retries = HttpConfig().max_retries
244
+ if backoff_base_seconds is None:
245
+ backoff_base_seconds = HttpConfig().backoff_base_seconds
246
+ try:
247
+ connect_timeout_seconds = float(connect_timeout_seconds)
248
+ except (TypeError, ValueError) as exc:
249
+ raise ConfigError("http.connect_timeout_seconds must be a number") from exc
250
+ try:
251
+ read_timeout_seconds = float(read_timeout_seconds)
252
+ except (TypeError, ValueError) as exc:
253
+ raise ConfigError("http.read_timeout_seconds must be a number") from exc
254
+ try:
255
+ max_retries = int(max_retries)
256
+ except (TypeError, ValueError) as exc:
257
+ raise ConfigError("http.max_retries must be an integer") from exc
258
+ try:
259
+ backoff_base_seconds = float(backoff_base_seconds)
260
+ except (TypeError, ValueError) as exc:
261
+ raise ConfigError("http.backoff_base_seconds must be a number") from exc
262
+
263
+ return HttpConfig(
264
+ allowed_domains=allowed_domains,
265
+ connect_timeout_seconds=connect_timeout_seconds,
266
+ read_timeout_seconds=read_timeout_seconds,
267
+ max_retries=max_retries,
268
+ backoff_base_seconds=backoff_base_seconds,
269
+ )
270
+
271
+ return HttpConfig()
272
+
273
+
163
274
 
164
275
 
165
276
  def _interpolate_env_vars(
@@ -290,9 +401,43 @@ def _parse_env_float(key: str) -> float:
290
401
  raise ConfigError(f"BRAWNY_{key} must be a number, got: {value}")
291
402
 
292
403
 
404
+ _ADVANCED_ENV_MAPPING: dict[str, tuple[str, Callable[[str], object]]] = {
405
+ "POLL_INTERVAL_SECONDS": ("poll_interval_seconds", _parse_env_float),
406
+ "REORG_DEPTH": ("reorg_depth", _parse_env_int),
407
+ "FINALITY_CONFIRMATIONS": ("finality_confirmations", _parse_env_int),
408
+ "DEFAULT_DEADLINE_SECONDS": ("default_deadline_seconds", _parse_env_int),
409
+ "STUCK_TX_SECONDS": ("stuck_tx_seconds", _parse_env_int),
410
+ "MAX_REPLACEMENT_ATTEMPTS": ("max_replacement_attempts", _parse_env_int),
411
+ "GAS_LIMIT_MULTIPLIER": ("gas_limit_multiplier", _parse_env_float),
412
+ "DEFAULT_PRIORITY_FEE_GWEI": ("default_priority_fee_gwei", _parse_env_float),
413
+ "MAX_FEE_CAP_GWEI": ("max_fee_cap_gwei", _parse_env_float),
414
+ "FEE_BUMP_PERCENT": ("fee_bump_percent", _parse_env_int),
415
+ "RPC_TIMEOUT_SECONDS": ("rpc_timeout_seconds", _parse_env_float),
416
+ "RPC_MAX_RETRIES": ("rpc_max_retries", _parse_env_int),
417
+ }
418
+
419
+
420
+ def _get_advanced_env_overrides() -> dict[str, object]:
421
+ overrides: dict[str, object] = {}
422
+ for env_key, (field_name, parser) in _ADVANCED_ENV_MAPPING.items():
423
+ if _env_is_set(env_key):
424
+ overrides[field_name] = parser(env_key)
425
+ return overrides
426
+
427
+
428
+ def _get_advanced_env_overrides_with_keys() -> tuple[dict[str, object], list[str]]:
429
+ overrides: dict[str, object] = {}
430
+ overridden: list[str] = []
431
+ for env_key, (field_name, parser) in _ADVANCED_ENV_MAPPING.items():
432
+ if _env_is_set(env_key):
433
+ overrides[field_name] = parser(env_key)
434
+ overridden.append(f"advanced.{field_name}")
435
+ return overrides, overridden
436
+
437
+
293
438
  def from_env() -> "Config":
294
439
  """Load configuration from environment variables."""
295
- from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig
440
+ from brawny.config.models import AdvancedConfig, Config, GuardrailsConfig, RPCGroupConfig
296
441
  from brawny.config.validation import canonicalize_endpoint, dedupe_preserve_order, InvalidEndpointError
297
442
 
298
443
  _fail_removed_env_vars()
@@ -328,35 +473,7 @@ def from_env() -> "Config":
328
473
  f"Must be one of: {', '.join(kt.value for kt in KeystoreType)}"
329
474
  )
330
475
 
331
- advanced_kwargs: dict[str, object] = {}
332
- if _env_is_set("POLL_INTERVAL_SECONDS"):
333
- advanced_kwargs["poll_interval_seconds"] = _parse_env_float("POLL_INTERVAL_SECONDS")
334
- if _env_is_set("REORG_DEPTH"):
335
- advanced_kwargs["reorg_depth"] = _parse_env_int("REORG_DEPTH")
336
- if _env_is_set("FINALITY_CONFIRMATIONS"):
337
- advanced_kwargs["finality_confirmations"] = _parse_env_int("FINALITY_CONFIRMATIONS")
338
- if _env_is_set("DEFAULT_DEADLINE_SECONDS"):
339
- advanced_kwargs["default_deadline_seconds"] = _parse_env_int("DEFAULT_DEADLINE_SECONDS")
340
- if _env_is_set("STUCK_TX_SECONDS"):
341
- advanced_kwargs["stuck_tx_seconds"] = _parse_env_int("STUCK_TX_SECONDS")
342
- if _env_is_set("MAX_REPLACEMENT_ATTEMPTS"):
343
- advanced_kwargs["max_replacement_attempts"] = _parse_env_int("MAX_REPLACEMENT_ATTEMPTS")
344
- if _env_is_set("GAS_LIMIT_MULTIPLIER"):
345
- advanced_kwargs["gas_limit_multiplier"] = _parse_env_float("GAS_LIMIT_MULTIPLIER")
346
- if _env_is_set("DEFAULT_PRIORITY_FEE_GWEI"):
347
- advanced_kwargs["default_priority_fee_gwei"] = _parse_env_float("DEFAULT_PRIORITY_FEE_GWEI")
348
- if _env_is_set("MAX_FEE_CAP_GWEI"):
349
- advanced_kwargs["max_fee_cap_gwei"] = _parse_env_float("MAX_FEE_CAP_GWEI")
350
- if _env_is_set("FEE_BUMP_PERCENT"):
351
- advanced_kwargs["fee_bump_percent"] = _parse_env_int("FEE_BUMP_PERCENT")
352
- if _env_is_set("RPC_TIMEOUT_SECONDS"):
353
- advanced_kwargs["rpc_timeout_seconds"] = _parse_env_float("RPC_TIMEOUT_SECONDS")
354
- if _env_is_set("RPC_MAX_RETRIES"):
355
- advanced_kwargs["rpc_max_retries"] = _parse_env_int("RPC_MAX_RETRIES")
356
- if _env_is_set("DATABASE_POOL_SIZE"):
357
- advanced_kwargs["database_pool_size"] = _parse_env_int("DATABASE_POOL_SIZE")
358
- if _env_is_set("DATABASE_POOL_MAX_OVERFLOW"):
359
- advanced_kwargs["database_pool_max_overflow"] = _parse_env_int("DATABASE_POOL_MAX_OVERFLOW")
476
+ advanced_kwargs = _get_advanced_env_overrides()
360
477
 
361
478
 
362
479
  # Parse telegram config from env (legacy format)
@@ -364,6 +481,20 @@ def from_env() -> "Config":
364
481
  "telegram_bot_token": _get_env("TELEGRAM_BOT_TOKEN"),
365
482
  "telegram_chat_id": _get_env("TELEGRAM_CHAT_ID"),
366
483
  })
484
+ guardrails_config = _parse_guardrails({
485
+ "guardrails": {
486
+ "lint_paths": _get_env_list("GUARDRAILS_LINT_PATHS"),
487
+ }
488
+ })
489
+ http_allowed = _get_env("HTTP_ALLOWED_DOMAINS")
490
+ http_config = _parse_http({
491
+ "http": {
492
+ "allowed_domains": [d.strip() for d in http_allowed.split(",")] if http_allowed else [],
493
+ "connect_timeout_seconds": _get_env("HTTP_CONNECT_TIMEOUT_SECONDS"),
494
+ "read_timeout_seconds": _get_env("HTTP_READ_TIMEOUT_SECONDS"),
495
+ "max_retries": _get_env("HTTP_MAX_RETRIES"),
496
+ }
497
+ })
367
498
 
368
499
  config = Config(
369
500
  database_url=database_url,
@@ -374,6 +505,9 @@ def from_env() -> "Config":
374
505
  worker_count=_get_env_int("WORKER_COUNT", 1),
375
506
  advanced=AdvancedConfig(**advanced_kwargs) if advanced_kwargs else None,
376
507
  telegram=telegram_config,
508
+ guardrails=guardrails_config,
509
+ http=http_config,
510
+ intent_cooldown=IntentCooldownConfig(),
377
511
  keystore_type=keystore_type,
378
512
  keystore_path=_get_env("KEYSTORE_PATH", "~/.brawny/keys") or "~/.brawny/keys",
379
513
  )
@@ -397,7 +531,7 @@ def from_yaml(path: str) -> "Config":
397
531
 
398
532
  Empty/unset variables in lists are automatically filtered out.
399
533
  """
400
- from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig
534
+ from brawny.config.models import AdvancedConfig, Config, IntentCooldownConfig, RPCGroupConfig
401
535
  from brawny.config.validation import (
402
536
  canonicalize_endpoint,
403
537
  dedupe_preserve_order,
@@ -431,6 +565,24 @@ def from_yaml(path: str) -> "Config":
431
565
 
432
566
  validate_no_removed_fields(data)
433
567
 
568
+ guardrails_data: dict[str, object] = {}
569
+ if "guardrails" in data:
570
+ guardrails_value = data.pop("guardrails")
571
+ if guardrails_value is None:
572
+ guardrails_value = {}
573
+ if not isinstance(guardrails_value, dict):
574
+ raise ConfigError("guardrails must be a mapping")
575
+ guardrails_data = dict(guardrails_value)
576
+
577
+ debug_data: dict[str, object] = {}
578
+ if "debug" in data:
579
+ debug_value = data.pop("debug")
580
+ if debug_value is None:
581
+ debug_value = {}
582
+ if not isinstance(debug_value, dict):
583
+ raise ConfigError("debug must be a mapping")
584
+ debug_data = dict(debug_value)
585
+
434
586
  advanced_data: dict[str, object] = {}
435
587
  if "advanced" in data:
436
588
  advanced_value = data.pop("advanced")
@@ -499,14 +651,28 @@ def from_yaml(path: str) -> "Config":
499
651
  if unknown:
500
652
  raise ConfigError(f"Unknown advanced config fields: {sorted(unknown)}")
501
653
  data["advanced"] = AdvancedConfig(**advanced_data)
654
+ if guardrails_data:
655
+ data["guardrails"] = guardrails_data
502
656
 
503
657
  # Parse telegram config (handles both new and legacy formats)
504
658
  telegram_config = _parse_telegram(data)
659
+ http_config = _parse_http(data)
660
+ guardrails_config = _parse_guardrails(data)
661
+ debug_config = _parse_debug(data)
662
+ intent_cooldown_config = _parse_intent_cooldown(data)
505
663
  # Remove raw telegram fields - they've been canonicalized
506
664
  data.pop("telegram", None)
507
665
  data.pop("telegram_bot_token", None)
508
666
  data.pop("telegram_chat_id", None)
509
667
  data["telegram"] = telegram_config
668
+ data.pop("http", None)
669
+ data["http"] = http_config
670
+ data.pop("guardrails", None)
671
+ data["guardrails"] = guardrails_config
672
+ data.pop("debug", None)
673
+ data["debug"] = debug_config
674
+ data.pop("intent_cooldown", None)
675
+ data["intent_cooldown"] = intent_cooldown_config
510
676
 
511
677
  config = Config(**data)
512
678
  config.validate()
@@ -515,13 +681,21 @@ def from_yaml(path: str) -> "Config":
515
681
 
516
682
  def apply_env_overrides(config: "Config") -> tuple["Config", list[str]]:
517
683
  """Apply environment overrides to the current config."""
518
- from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig, TelegramConfig
684
+ from brawny.config.models import (
685
+ AdvancedConfig,
686
+ Config,
687
+ GuardrailsConfig,
688
+ RPCGroupConfig,
689
+ TelegramConfig,
690
+ )
691
+ from brawny.http import HttpConfig
519
692
  from brawny.config.validation import canonicalize_endpoint, dedupe_preserve_order, InvalidEndpointError
520
693
 
521
694
  _fail_removed_env_vars()
522
695
 
523
696
  overrides: dict[str, object] = {}
524
697
  advanced_overrides: dict[str, object] = {}
698
+ guardrails_overrides: dict[str, object] = {}
525
699
  overridden: list[str] = []
526
700
 
527
701
  mapping = {
@@ -573,29 +747,63 @@ def apply_env_overrides(config: "Config") -> tuple["Config", list[str]]:
573
747
  if telegram_chat_override is not None:
574
748
  overridden.append("telegram.chat_id")
575
749
 
576
- advanced_mapping = {
577
- "POLL_INTERVAL_SECONDS": ("poll_interval_seconds", _parse_env_float),
578
- "REORG_DEPTH": ("reorg_depth", _parse_env_int),
579
- "FINALITY_CONFIRMATIONS": ("finality_confirmations", _parse_env_int),
580
- "DEFAULT_DEADLINE_SECONDS": ("default_deadline_seconds", _parse_env_int),
581
- "STUCK_TX_SECONDS": ("stuck_tx_seconds", _parse_env_int),
582
- "MAX_REPLACEMENT_ATTEMPTS": ("max_replacement_attempts", _parse_env_int),
583
- "GAS_LIMIT_MULTIPLIER": ("gas_limit_multiplier", _parse_env_float),
584
- "DEFAULT_PRIORITY_FEE_GWEI": ("default_priority_fee_gwei", _parse_env_float),
585
- "MAX_FEE_CAP_GWEI": ("max_fee_cap_gwei", _parse_env_float),
586
- "FEE_BUMP_PERCENT": ("fee_bump_percent", _parse_env_int),
587
- "RPC_TIMEOUT_SECONDS": ("rpc_timeout_seconds", _parse_env_float),
588
- "RPC_MAX_RETRIES": ("rpc_max_retries", _parse_env_int),
589
- "DATABASE_POOL_SIZE": ("database_pool_size", _parse_env_int),
590
- "DATABASE_POOL_MAX_OVERFLOW": ("database_pool_max_overflow", _parse_env_int),
591
- }
592
-
593
- for env_key, (field_name, parser) in advanced_mapping.items():
594
- if not _env_is_set(env_key):
595
- continue
596
- value = parser(env_key)
597
- advanced_overrides[field_name] = value
598
- overridden.append(f"advanced.{field_name}")
750
+ # Handle http env overrides
751
+ if (
752
+ _env_is_set("HTTP_ALLOWED_DOMAINS")
753
+ or _env_is_set("HTTP_CONNECT_TIMEOUT_SECONDS")
754
+ or _env_is_set("HTTP_READ_TIMEOUT_SECONDS")
755
+ or _env_is_set("HTTP_MAX_RETRIES")
756
+ or _env_is_set("HTTP_BACKOFF_BASE_SECONDS")
757
+ ):
758
+ base_http = config.http
759
+ allowed_raw = _get_env("HTTP_ALLOWED_DOMAINS")
760
+ allowed_domains = (
761
+ [d.strip() for d in allowed_raw.split(",") if d.strip()] if allowed_raw is not None else base_http.allowed_domains
762
+ )
763
+ connect_timeout_seconds = (
764
+ _parse_env_float("HTTP_CONNECT_TIMEOUT_SECONDS")
765
+ if _env_is_set("HTTP_CONNECT_TIMEOUT_SECONDS")
766
+ else base_http.connect_timeout_seconds
767
+ )
768
+ read_timeout_seconds = (
769
+ _parse_env_float("HTTP_READ_TIMEOUT_SECONDS")
770
+ if _env_is_set("HTTP_READ_TIMEOUT_SECONDS")
771
+ else base_http.read_timeout_seconds
772
+ )
773
+ max_retries = (
774
+ _parse_env_int("HTTP_MAX_RETRIES")
775
+ if _env_is_set("HTTP_MAX_RETRIES")
776
+ else base_http.max_retries
777
+ )
778
+ backoff_base_seconds = (
779
+ _parse_env_float("HTTP_BACKOFF_BASE_SECONDS")
780
+ if _env_is_set("HTTP_BACKOFF_BASE_SECONDS")
781
+ else base_http.backoff_base_seconds
782
+ )
783
+ overrides["http"] = HttpConfig(
784
+ allowed_domains=allowed_domains,
785
+ connect_timeout_seconds=connect_timeout_seconds,
786
+ read_timeout_seconds=read_timeout_seconds,
787
+ max_retries=max_retries,
788
+ backoff_base_seconds=backoff_base_seconds,
789
+ )
790
+ if _env_is_set("HTTP_ALLOWED_DOMAINS"):
791
+ overridden.append("http.allowed_domains")
792
+ if _env_is_set("HTTP_CONNECT_TIMEOUT_SECONDS"):
793
+ overridden.append("http.connect_timeout_seconds")
794
+ if _env_is_set("HTTP_READ_TIMEOUT_SECONDS"):
795
+ overridden.append("http.read_timeout_seconds")
796
+ if _env_is_set("HTTP_MAX_RETRIES"):
797
+ overridden.append("http.max_retries")
798
+ if _env_is_set("HTTP_BACKOFF_BASE_SECONDS"):
799
+ overridden.append("http.backoff_base_seconds")
800
+
801
+ if _env_is_set("GUARDRAILS_LINT_PATHS"):
802
+ guardrails_overrides["lint_paths"] = _get_env_list("GUARDRAILS_LINT_PATHS")
803
+ overridden.append("guardrails.lint_paths")
804
+
805
+ advanced_overrides, advanced_overridden = _get_advanced_env_overrides_with_keys()
806
+ overridden.extend(advanced_overridden)
599
807
 
600
808
  rpc_endpoints_override: list[str] | None = None
601
809
  if _env_is_set("RPC_ENDPOINTS"):
@@ -617,6 +825,9 @@ def apply_env_overrides(config: "Config") -> tuple["Config", list[str]]:
617
825
  if advanced_overrides:
618
826
  base_advanced = config.advanced or AdvancedConfig()
619
827
  overrides["advanced"] = replace(base_advanced, **advanced_overrides)
828
+ if guardrails_overrides:
829
+ base_guardrails = config.guardrails or GuardrailsConfig()
830
+ overrides["guardrails"] = replace(base_guardrails, **guardrails_overrides)
620
831
 
621
832
  if rpc_endpoints_override is not None:
622
833
  default_group = overrides.get("rpc_default_group") or config.rpc_default_group or "primary"