brawny 0.1.13__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 +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- brawny-0.1.13.dist-info/top_level.txt +1 -0
brawny/config/parser.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""Configuration parsing for brawny.
|
|
2
|
+
|
|
3
|
+
Provides functions to load config from YAML files and environment variables.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
|
|
12
|
+
from brawny.model.enums import KeystoreType
|
|
13
|
+
from brawny.model.errors import ConfigError
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import yaml
|
|
17
|
+
except ImportError: # pragma: no cover - handled by dependency management
|
|
18
|
+
yaml = None
|
|
19
|
+
|
|
20
|
+
# Pattern to match ${VAR_NAME}, ${VAR_NAME:-default}, or ${{VAR_NAME}} forms
|
|
21
|
+
ENV_VAR_PATTERN = re.compile(r"\$\{\{?([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}?\}")
|
|
22
|
+
|
|
23
|
+
_ADVANCED_FIELDS = {
|
|
24
|
+
"poll_interval_seconds",
|
|
25
|
+
"reorg_depth",
|
|
26
|
+
"finality_confirmations",
|
|
27
|
+
"default_deadline_seconds",
|
|
28
|
+
"stuck_tx_seconds",
|
|
29
|
+
"max_replacement_attempts",
|
|
30
|
+
"gas_limit_multiplier",
|
|
31
|
+
"default_priority_fee_gwei",
|
|
32
|
+
"max_fee_cap_gwei",
|
|
33
|
+
"fee_bump_percent",
|
|
34
|
+
"rpc_timeout_seconds",
|
|
35
|
+
"rpc_max_retries",
|
|
36
|
+
"database_pool_size",
|
|
37
|
+
"database_pool_max_overflow",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_REMOVED_ENV_KEYS = {
|
|
41
|
+
"ALERTS_DX_ENABLED",
|
|
42
|
+
"ALLOWED_SIGNERS",
|
|
43
|
+
"BLOCK_HASH_HISTORY_SIZE",
|
|
44
|
+
"ENABLE_BROWNIE_PASSWORD_FALLBACK",
|
|
45
|
+
"CLAIM_TIMEOUT_SECONDS",
|
|
46
|
+
"DB_CIRCUIT_BREAKER_FAILURES",
|
|
47
|
+
"DB_CIRCUIT_BREAKER_SECONDS",
|
|
48
|
+
"DEEP_REORG_PAUSE",
|
|
49
|
+
"DEEP_REORG_ALERT_ENABLED",
|
|
50
|
+
"ETHERSCAN_API_URL",
|
|
51
|
+
"INTENT_RETRY_BACKOFF_SECONDS",
|
|
52
|
+
"JOB_MODULES",
|
|
53
|
+
"JOBS_PATH",
|
|
54
|
+
"LOG_FORMAT",
|
|
55
|
+
"RPC_CIRCUIT_BREAKER_SECONDS",
|
|
56
|
+
"SHUTDOWN_GRACE_SECONDS",
|
|
57
|
+
"SHUTDOWN_TIMEOUT_SECONDS",
|
|
58
|
+
"MAX_FEE",
|
|
59
|
+
"METRICS_BIND",
|
|
60
|
+
"METRICS_ENABLED",
|
|
61
|
+
"PRIORITY_FEE",
|
|
62
|
+
"SOURCIFY_ENABLED",
|
|
63
|
+
"TELEGRAM_CHAT_IDS",
|
|
64
|
+
"WEBHOOK_URL",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_chat_id(s: str) -> bool:
|
|
69
|
+
"""Check if string looks like a raw Telegram chat ID."""
|
|
70
|
+
return s.lstrip("-").isdigit()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_telegram(raw: dict) -> "TelegramConfig":
|
|
74
|
+
"""Parse telegram config, handling both old and new formats.
|
|
75
|
+
|
|
76
|
+
Normalizes all inputs (strips whitespace) and canonicalizes to TelegramConfig.
|
|
77
|
+
"""
|
|
78
|
+
from brawny.config.models import TelegramConfig
|
|
79
|
+
|
|
80
|
+
# New format: telegram.bot_token, telegram.chats, telegram.default, telegram.parse_mode
|
|
81
|
+
if "telegram" in raw and isinstance(raw["telegram"], dict):
|
|
82
|
+
tg = raw["telegram"]
|
|
83
|
+
|
|
84
|
+
# Normalize bot_token
|
|
85
|
+
bot_token = tg.get("bot_token")
|
|
86
|
+
if bot_token:
|
|
87
|
+
bot_token = bot_token.strip()
|
|
88
|
+
|
|
89
|
+
# Normalize chats (strip keys and values, validate IDs look numeric)
|
|
90
|
+
raw_chats = tg.get("chats", {})
|
|
91
|
+
chats: dict[str, str] = {}
|
|
92
|
+
for k, v in raw_chats.items():
|
|
93
|
+
if not k or not v:
|
|
94
|
+
continue
|
|
95
|
+
k = k.strip()
|
|
96
|
+
v = str(v).strip()
|
|
97
|
+
if not _is_chat_id(v):
|
|
98
|
+
raise ConfigError(f"telegram.chats.{k} must be a numeric chat ID, got: '{v}'")
|
|
99
|
+
chats[k] = v
|
|
100
|
+
|
|
101
|
+
# Normalize default to list, strip each entry
|
|
102
|
+
default = tg.get("default", [])
|
|
103
|
+
if isinstance(default, str):
|
|
104
|
+
default = [default]
|
|
105
|
+
default = [d.strip() for d in default if d and str(d).strip()]
|
|
106
|
+
|
|
107
|
+
parse_mode = tg.get("parse_mode")
|
|
108
|
+
if isinstance(parse_mode, str):
|
|
109
|
+
parse_mode = parse_mode.strip() or None
|
|
110
|
+
if parse_mode not in (None, "Markdown", "MarkdownV2", "HTML"):
|
|
111
|
+
raise ConfigError(
|
|
112
|
+
"telegram.parse_mode must be one of: Markdown, MarkdownV2, HTML, or null"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
health_chat = tg.get("health_chat")
|
|
116
|
+
if isinstance(health_chat, str):
|
|
117
|
+
health_chat = health_chat.strip() or None
|
|
118
|
+
|
|
119
|
+
health_cooldown = tg.get("health_cooldown_seconds")
|
|
120
|
+
if health_cooldown is not None:
|
|
121
|
+
try:
|
|
122
|
+
health_cooldown = int(health_cooldown)
|
|
123
|
+
except (TypeError, ValueError) as exc:
|
|
124
|
+
raise ConfigError("telegram.health_cooldown_seconds must be an integer") from exc
|
|
125
|
+
|
|
126
|
+
if health_cooldown is None:
|
|
127
|
+
health_cooldown = TelegramConfig().health_cooldown_seconds
|
|
128
|
+
|
|
129
|
+
return TelegramConfig(
|
|
130
|
+
bot_token=bot_token,
|
|
131
|
+
chats=chats,
|
|
132
|
+
default=default,
|
|
133
|
+
parse_mode=parse_mode,
|
|
134
|
+
health_chat=health_chat,
|
|
135
|
+
health_cooldown_seconds=health_cooldown,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Legacy format: telegram_bot_token, telegram_chat_id
|
|
139
|
+
bot_token = raw.get("telegram_bot_token")
|
|
140
|
+
chat_id = raw.get("telegram_chat_id")
|
|
141
|
+
|
|
142
|
+
if bot_token:
|
|
143
|
+
bot_token = str(bot_token).strip()
|
|
144
|
+
if chat_id:
|
|
145
|
+
chat_id = str(chat_id).strip()
|
|
146
|
+
# Validate legacy chat_id is numeric too
|
|
147
|
+
if not _is_chat_id(chat_id):
|
|
148
|
+
raise ConfigError(f"telegram_chat_id must be numeric, got: '{chat_id}'")
|
|
149
|
+
|
|
150
|
+
if bot_token or chat_id:
|
|
151
|
+
# Migrate to canonical form
|
|
152
|
+
chats = {"default": chat_id} if chat_id else {}
|
|
153
|
+
default = ["default"] if chat_id else []
|
|
154
|
+
return TelegramConfig(
|
|
155
|
+
bot_token=bot_token,
|
|
156
|
+
chats=chats,
|
|
157
|
+
default=default,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return TelegramConfig()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _interpolate_env_vars(
|
|
166
|
+
value: object,
|
|
167
|
+
missing: list[str] | None = None,
|
|
168
|
+
path: str = "",
|
|
169
|
+
) -> object:
|
|
170
|
+
"""Recursively interpolate ${VAR}, ${VAR:-default}, and ${{VAR}} patterns in config values.
|
|
171
|
+
|
|
172
|
+
Supports:
|
|
173
|
+
- ${VAR_NAME} / ${{VAR_NAME}} - replaced with env var value, empty string if not set
|
|
174
|
+
- ${VAR_NAME:-default} / ${{VAR_NAME:-default}} - replaced with env var value, or default if not set
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
value: Config value (string, list, dict, or primitive)
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Value with environment variables interpolated
|
|
181
|
+
"""
|
|
182
|
+
if isinstance(value, str):
|
|
183
|
+
def replacer(match: re.Match[str]) -> str:
|
|
184
|
+
var_name = match.group(1)
|
|
185
|
+
default_val = match.group(2) # None if no default specified
|
|
186
|
+
env_val = os.environ.get(var_name)
|
|
187
|
+
if env_val is not None:
|
|
188
|
+
return env_val
|
|
189
|
+
if default_val is not None:
|
|
190
|
+
return default_val
|
|
191
|
+
if missing is not None:
|
|
192
|
+
location = path or "<root>"
|
|
193
|
+
missing.append(f"{var_name} (at {location})")
|
|
194
|
+
return "" # Return empty string for unset vars without default
|
|
195
|
+
|
|
196
|
+
result = ENV_VAR_PATTERN.sub(replacer, value)
|
|
197
|
+
# If the entire string was a variable that resolved to empty, return None
|
|
198
|
+
# This allows filtering out empty RPC endpoints
|
|
199
|
+
if result == "" and ENV_VAR_PATTERN.search(value):
|
|
200
|
+
return None
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
elif isinstance(value, list):
|
|
204
|
+
interpolated = [
|
|
205
|
+
_interpolate_env_vars(item, missing, f"{path}[{idx}]")
|
|
206
|
+
for idx, item in enumerate(value)
|
|
207
|
+
]
|
|
208
|
+
# Filter out None values (unset env vars) and empty strings from lists
|
|
209
|
+
return [v for v in interpolated if v is not None and v != ""]
|
|
210
|
+
|
|
211
|
+
elif isinstance(value, dict):
|
|
212
|
+
return {
|
|
213
|
+
k: _interpolate_env_vars(v, missing, f"{path}.{k}" if path else str(k))
|
|
214
|
+
for k, v in value.items()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
else:
|
|
218
|
+
return value
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _get_env(key: str, default: str | None = None, required: bool = False) -> str | None:
|
|
222
|
+
"""Get environment variable with BRAWNY_ prefix."""
|
|
223
|
+
full_key = f"BRAWNY_{key}"
|
|
224
|
+
value = os.environ.get(full_key, default)
|
|
225
|
+
if required and not value:
|
|
226
|
+
raise ConfigError(f"Required environment variable {full_key} is not set")
|
|
227
|
+
return value
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _env_is_set(key: str) -> bool:
|
|
231
|
+
return f"BRAWNY_{key}" in os.environ
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _fail_removed_env_vars() -> None:
|
|
235
|
+
removed = [key for key in _REMOVED_ENV_KEYS if _env_is_set(key)]
|
|
236
|
+
if removed:
|
|
237
|
+
raise ConfigError(
|
|
238
|
+
"Removed config options detected in environment: "
|
|
239
|
+
f"{sorted(removed)}. These options no longer exist."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _get_env_list(key: str, default: list[str] | None = None) -> list[str]:
|
|
244
|
+
"""Get comma-separated list from environment variable."""
|
|
245
|
+
value = _get_env(key)
|
|
246
|
+
if not value:
|
|
247
|
+
return default or []
|
|
248
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _get_env_int(key: str, default: int) -> int:
|
|
252
|
+
"""Get integer from environment variable."""
|
|
253
|
+
value = _get_env(key)
|
|
254
|
+
if not value:
|
|
255
|
+
return default
|
|
256
|
+
try:
|
|
257
|
+
return int(value)
|
|
258
|
+
except ValueError:
|
|
259
|
+
raise ConfigError(f"BRAWNY_{key} must be an integer, got: {value}")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _get_env_float(key: str, default: float) -> float:
|
|
263
|
+
"""Get float from environment variable."""
|
|
264
|
+
value = _get_env(key)
|
|
265
|
+
if not value:
|
|
266
|
+
return default
|
|
267
|
+
try:
|
|
268
|
+
return float(value)
|
|
269
|
+
except ValueError:
|
|
270
|
+
raise ConfigError(f"BRAWNY_{key} must be a number, got: {value}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_env_int(key: str) -> int:
|
|
274
|
+
value = _get_env(key)
|
|
275
|
+
if value is None:
|
|
276
|
+
raise ConfigError(f"Missing env override BRAWNY_{key}")
|
|
277
|
+
try:
|
|
278
|
+
return int(value)
|
|
279
|
+
except ValueError:
|
|
280
|
+
raise ConfigError(f"BRAWNY_{key} must be an integer, got: {value}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parse_env_float(key: str) -> float:
|
|
284
|
+
value = _get_env(key)
|
|
285
|
+
if value is None:
|
|
286
|
+
raise ConfigError(f"Missing env override BRAWNY_{key}")
|
|
287
|
+
try:
|
|
288
|
+
return float(value)
|
|
289
|
+
except ValueError:
|
|
290
|
+
raise ConfigError(f"BRAWNY_{key} must be a number, got: {value}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def from_env() -> "Config":
|
|
294
|
+
"""Load configuration from environment variables."""
|
|
295
|
+
from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig
|
|
296
|
+
from brawny.config.validation import canonicalize_endpoint, dedupe_preserve_order, InvalidEndpointError
|
|
297
|
+
|
|
298
|
+
_fail_removed_env_vars()
|
|
299
|
+
|
|
300
|
+
# Get required values
|
|
301
|
+
database_url = _get_env("DATABASE_URL", required=True)
|
|
302
|
+
if database_url is None:
|
|
303
|
+
raise ConfigError("BRAWNY_DATABASE_URL is required")
|
|
304
|
+
|
|
305
|
+
rpc_endpoints = _get_env_list("RPC_ENDPOINTS")
|
|
306
|
+
if not rpc_endpoints:
|
|
307
|
+
raise ConfigError("BRAWNY_RPC_ENDPOINTS is required (comma-separated list)")
|
|
308
|
+
|
|
309
|
+
endpoints: list[str] = []
|
|
310
|
+
for i, endpoint in enumerate(rpc_endpoints):
|
|
311
|
+
try:
|
|
312
|
+
endpoints.append(canonicalize_endpoint(endpoint))
|
|
313
|
+
except InvalidEndpointError as e:
|
|
314
|
+
raise ConfigError(f"rpc_endpoints[{i}]: {e}") from e
|
|
315
|
+
endpoints = dedupe_preserve_order(endpoints)
|
|
316
|
+
|
|
317
|
+
rpc_default_group = _get_env("RPC_DEFAULT_GROUP") or "primary"
|
|
318
|
+
|
|
319
|
+
chain_id = _get_env_int("CHAIN_ID", 1)
|
|
320
|
+
|
|
321
|
+
# Parse keystore type
|
|
322
|
+
keystore_type_str = _get_env("KEYSTORE_TYPE", "file")
|
|
323
|
+
try:
|
|
324
|
+
keystore_type = KeystoreType(keystore_type_str)
|
|
325
|
+
except ValueError:
|
|
326
|
+
raise ConfigError(
|
|
327
|
+
f"Invalid keystore type: {keystore_type_str}. "
|
|
328
|
+
f"Must be one of: {', '.join(kt.value for kt in KeystoreType)}"
|
|
329
|
+
)
|
|
330
|
+
|
|
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")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# Parse telegram config from env (legacy format)
|
|
363
|
+
telegram_config = _parse_telegram({
|
|
364
|
+
"telegram_bot_token": _get_env("TELEGRAM_BOT_TOKEN"),
|
|
365
|
+
"telegram_chat_id": _get_env("TELEGRAM_CHAT_ID"),
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
config = Config(
|
|
369
|
+
database_url=database_url,
|
|
370
|
+
rpc_endpoints=endpoints,
|
|
371
|
+
rpc_groups={rpc_default_group: RPCGroupConfig(endpoints=endpoints)},
|
|
372
|
+
rpc_default_group=rpc_default_group,
|
|
373
|
+
chain_id=chain_id,
|
|
374
|
+
worker_count=_get_env_int("WORKER_COUNT", 1),
|
|
375
|
+
advanced=AdvancedConfig(**advanced_kwargs) if advanced_kwargs else None,
|
|
376
|
+
telegram=telegram_config,
|
|
377
|
+
keystore_type=keystore_type,
|
|
378
|
+
keystore_path=_get_env("KEYSTORE_PATH", "~/.brawny/keys") or "~/.brawny/keys",
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
config.validate()
|
|
382
|
+
return config
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def from_yaml(path: str) -> "Config":
|
|
386
|
+
"""Load configuration from a YAML file.
|
|
387
|
+
|
|
388
|
+
Supports environment variable interpolation using ${VAR}, ${{VAR}}, or ${VAR:-default} syntax.
|
|
389
|
+
For example:
|
|
390
|
+
rpc_groups:
|
|
391
|
+
primary:
|
|
392
|
+
endpoints:
|
|
393
|
+
- ${RPC_1}
|
|
394
|
+
- ${RPC_2:-http://localhost:8545}
|
|
395
|
+
- ${{RPC_3}}
|
|
396
|
+
rpc_default_group: primary
|
|
397
|
+
|
|
398
|
+
Empty/unset variables in lists are automatically filtered out.
|
|
399
|
+
"""
|
|
400
|
+
from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig
|
|
401
|
+
from brawny.config.validation import (
|
|
402
|
+
canonicalize_endpoint,
|
|
403
|
+
dedupe_preserve_order,
|
|
404
|
+
InvalidEndpointError,
|
|
405
|
+
validate_no_removed_fields,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if yaml is None:
|
|
409
|
+
raise ConfigError("PyYAML is required for YAML config support.")
|
|
410
|
+
try:
|
|
411
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
412
|
+
data = yaml.safe_load(handle) or {}
|
|
413
|
+
except FileNotFoundError as e:
|
|
414
|
+
raise ConfigError(f"Config file not found: {path}") from e
|
|
415
|
+
except Exception as e:
|
|
416
|
+
raise ConfigError(f"Failed to read config file {path}: {e}") from e
|
|
417
|
+
|
|
418
|
+
if not isinstance(data, dict):
|
|
419
|
+
raise ConfigError("Config file must contain a mapping at the top level.")
|
|
420
|
+
|
|
421
|
+
# Interpolate environment variables in all config values
|
|
422
|
+
missing: list[str] = []
|
|
423
|
+
data = _interpolate_env_vars(data, missing)
|
|
424
|
+
if not isinstance(data, dict):
|
|
425
|
+
raise ConfigError("Config interpolation failed.")
|
|
426
|
+
if missing:
|
|
427
|
+
missing_list = ", ".join(sorted(set(missing)))
|
|
428
|
+
raise ConfigError(
|
|
429
|
+
f"Config interpolation failed. Missing environment variables: {missing_list}"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
validate_no_removed_fields(data)
|
|
433
|
+
|
|
434
|
+
advanced_data: dict[str, object] = {}
|
|
435
|
+
if "advanced" in data:
|
|
436
|
+
advanced_value = data.pop("advanced")
|
|
437
|
+
if advanced_value is None:
|
|
438
|
+
advanced_value = {}
|
|
439
|
+
if not isinstance(advanced_value, dict):
|
|
440
|
+
raise ConfigError("advanced must be a mapping")
|
|
441
|
+
advanced_data = dict(advanced_value)
|
|
442
|
+
|
|
443
|
+
# Parse rpc_groups with canonicalization + deduplication
|
|
444
|
+
rpc_groups_data = data.pop("rpc_groups", {})
|
|
445
|
+
rpc_groups: dict[str, RPCGroupConfig] = {}
|
|
446
|
+
|
|
447
|
+
if rpc_groups_data:
|
|
448
|
+
for name, group_data in rpc_groups_data.items():
|
|
449
|
+
if not isinstance(group_data, dict):
|
|
450
|
+
raise ConfigError(f"rpc_groups.{name} must be a mapping")
|
|
451
|
+
|
|
452
|
+
endpoints_raw = group_data.get("endpoints", [])
|
|
453
|
+
if not isinstance(endpoints_raw, list):
|
|
454
|
+
raise ConfigError(f"rpc_groups.{name}.endpoints must be a list")
|
|
455
|
+
|
|
456
|
+
# Strip → canonicalize → dedupe (once, here)
|
|
457
|
+
# InvalidEndpointError from canonicalize → ConfigError with context
|
|
458
|
+
endpoints = []
|
|
459
|
+
for i, ep in enumerate(endpoints_raw):
|
|
460
|
+
if not isinstance(ep, str):
|
|
461
|
+
raise ConfigError(f"rpc_groups.{name}.endpoints[{i}] must be string")
|
|
462
|
+
# Skip empty strings (from unset env vars)
|
|
463
|
+
if not ep.strip():
|
|
464
|
+
continue
|
|
465
|
+
try:
|
|
466
|
+
canonical = canonicalize_endpoint(ep)
|
|
467
|
+
endpoints.append(canonical)
|
|
468
|
+
except InvalidEndpointError as e:
|
|
469
|
+
raise ConfigError(f"rpc_groups.{name}.endpoints[{i}]: {e}") from e
|
|
470
|
+
|
|
471
|
+
original_count = len(endpoints)
|
|
472
|
+
endpoints = dedupe_preserve_order(endpoints)
|
|
473
|
+
if len(endpoints) != original_count:
|
|
474
|
+
# Log warning about deduplication (will be logged at config load time)
|
|
475
|
+
import logging
|
|
476
|
+
logging.getLogger(__name__).warning(
|
|
477
|
+
f"rpc_groups.{name}: removed {original_count - len(endpoints)} "
|
|
478
|
+
f"duplicate endpoint(s) after canonicalization"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
rpc_groups[name] = RPCGroupConfig(endpoints=endpoints)
|
|
482
|
+
|
|
483
|
+
data["rpc_groups"] = rpc_groups
|
|
484
|
+
|
|
485
|
+
# Optional default group for routing (may be None)
|
|
486
|
+
data["rpc_default_group"] = data.get("rpc_default_group")
|
|
487
|
+
|
|
488
|
+
# Derive rpc_endpoints from default or single group (internal use)
|
|
489
|
+
default_group = data.get("rpc_default_group")
|
|
490
|
+
if default_group and default_group in rpc_groups:
|
|
491
|
+
data["rpc_endpoints"] = rpc_groups[default_group].endpoints
|
|
492
|
+
elif len(rpc_groups) == 1:
|
|
493
|
+
data["rpc_endpoints"] = next(iter(rpc_groups.values())).endpoints
|
|
494
|
+
else:
|
|
495
|
+
data["rpc_endpoints"] = []
|
|
496
|
+
|
|
497
|
+
if advanced_data:
|
|
498
|
+
unknown = set(advanced_data.keys()) - _ADVANCED_FIELDS
|
|
499
|
+
if unknown:
|
|
500
|
+
raise ConfigError(f"Unknown advanced config fields: {sorted(unknown)}")
|
|
501
|
+
data["advanced"] = AdvancedConfig(**advanced_data)
|
|
502
|
+
|
|
503
|
+
# Parse telegram config (handles both new and legacy formats)
|
|
504
|
+
telegram_config = _parse_telegram(data)
|
|
505
|
+
# Remove raw telegram fields - they've been canonicalized
|
|
506
|
+
data.pop("telegram", None)
|
|
507
|
+
data.pop("telegram_bot_token", None)
|
|
508
|
+
data.pop("telegram_chat_id", None)
|
|
509
|
+
data["telegram"] = telegram_config
|
|
510
|
+
|
|
511
|
+
config = Config(**data)
|
|
512
|
+
config.validate()
|
|
513
|
+
return config
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def apply_env_overrides(config: "Config") -> tuple["Config", list[str]]:
|
|
517
|
+
"""Apply environment overrides to the current config."""
|
|
518
|
+
from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig, TelegramConfig
|
|
519
|
+
from brawny.config.validation import canonicalize_endpoint, dedupe_preserve_order, InvalidEndpointError
|
|
520
|
+
|
|
521
|
+
_fail_removed_env_vars()
|
|
522
|
+
|
|
523
|
+
overrides: dict[str, object] = {}
|
|
524
|
+
advanced_overrides: dict[str, object] = {}
|
|
525
|
+
overridden: list[str] = []
|
|
526
|
+
|
|
527
|
+
mapping = {
|
|
528
|
+
"DATABASE_URL": ("database_url", _get_env),
|
|
529
|
+
"RPC_DEFAULT_GROUP": ("rpc_default_group", _get_env),
|
|
530
|
+
"CHAIN_ID": ("chain_id", _parse_env_int),
|
|
531
|
+
"WORKER_COUNT": ("worker_count", _parse_env_int),
|
|
532
|
+
"KEYSTORE_TYPE": ("keystore_type", _get_env),
|
|
533
|
+
"KEYSTORE_PATH": ("keystore_path", _get_env),
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
for env_key, (field_name, parser) in mapping.items():
|
|
537
|
+
if not _env_is_set(env_key):
|
|
538
|
+
continue
|
|
539
|
+
value = parser(env_key)
|
|
540
|
+
if value is None:
|
|
541
|
+
continue
|
|
542
|
+
overrides[field_name] = value
|
|
543
|
+
overridden.append(field_name)
|
|
544
|
+
|
|
545
|
+
# Handle telegram env overrides (legacy format)
|
|
546
|
+
telegram_token_override = _get_env("TELEGRAM_BOT_TOKEN") if _env_is_set("TELEGRAM_BOT_TOKEN") else None
|
|
547
|
+
telegram_chat_override = _get_env("TELEGRAM_CHAT_ID") if _env_is_set("TELEGRAM_CHAT_ID") else None
|
|
548
|
+
if telegram_token_override is not None or telegram_chat_override is not None:
|
|
549
|
+
# Build new telegram config merging with existing
|
|
550
|
+
base_telegram = config.telegram
|
|
551
|
+
new_token = telegram_token_override.strip() if telegram_token_override else base_telegram.bot_token
|
|
552
|
+
new_chats = dict(base_telegram.chats)
|
|
553
|
+
new_default = list(base_telegram.default)
|
|
554
|
+
|
|
555
|
+
if telegram_chat_override:
|
|
556
|
+
chat_id = telegram_chat_override.strip()
|
|
557
|
+
if not _is_chat_id(chat_id):
|
|
558
|
+
raise ConfigError(f"BRAWNY_TELEGRAM_CHAT_ID must be numeric, got: '{chat_id}'")
|
|
559
|
+
new_chats["default"] = chat_id
|
|
560
|
+
if "default" not in new_default:
|
|
561
|
+
new_default = ["default"] + new_default
|
|
562
|
+
|
|
563
|
+
overrides["telegram"] = TelegramConfig(
|
|
564
|
+
bot_token=new_token,
|
|
565
|
+
chats=new_chats,
|
|
566
|
+
default=new_default,
|
|
567
|
+
parse_mode=base_telegram.parse_mode,
|
|
568
|
+
health_chat=base_telegram.health_chat,
|
|
569
|
+
health_cooldown_seconds=base_telegram.health_cooldown_seconds,
|
|
570
|
+
)
|
|
571
|
+
if telegram_token_override is not None:
|
|
572
|
+
overridden.append("telegram.bot_token")
|
|
573
|
+
if telegram_chat_override is not None:
|
|
574
|
+
overridden.append("telegram.chat_id")
|
|
575
|
+
|
|
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}")
|
|
599
|
+
|
|
600
|
+
rpc_endpoints_override: list[str] | None = None
|
|
601
|
+
if _env_is_set("RPC_ENDPOINTS"):
|
|
602
|
+
raw_endpoints = _get_env_list("RPC_ENDPOINTS")
|
|
603
|
+
endpoints: list[str] = []
|
|
604
|
+
for i, endpoint in enumerate(raw_endpoints):
|
|
605
|
+
try:
|
|
606
|
+
endpoints.append(canonicalize_endpoint(endpoint))
|
|
607
|
+
except InvalidEndpointError as e:
|
|
608
|
+
raise ConfigError(f"rpc_endpoints[{i}]: {e}") from e
|
|
609
|
+
rpc_endpoints_override = dedupe_preserve_order(endpoints)
|
|
610
|
+
|
|
611
|
+
if not overrides:
|
|
612
|
+
return config, []
|
|
613
|
+
|
|
614
|
+
if "keystore_type" in overrides:
|
|
615
|
+
overrides["keystore_type"] = KeystoreType(str(overrides["keystore_type"]))
|
|
616
|
+
|
|
617
|
+
if advanced_overrides:
|
|
618
|
+
base_advanced = config.advanced or AdvancedConfig()
|
|
619
|
+
overrides["advanced"] = replace(base_advanced, **advanced_overrides)
|
|
620
|
+
|
|
621
|
+
if rpc_endpoints_override is not None:
|
|
622
|
+
default_group = overrides.get("rpc_default_group") or config.rpc_default_group or "primary"
|
|
623
|
+
overrides["rpc_default_group"] = default_group
|
|
624
|
+
overrides["rpc_endpoints"] = rpc_endpoints_override
|
|
625
|
+
overrides["rpc_groups"] = {default_group: RPCGroupConfig(endpoints=rpc_endpoints_override)}
|
|
626
|
+
overridden.extend(["rpc_endpoints", "rpc_groups"])
|
|
627
|
+
|
|
628
|
+
if "rpc_default_group" in overrides:
|
|
629
|
+
default_group = str(overrides["rpc_default_group"])
|
|
630
|
+
if default_group in config.rpc_groups:
|
|
631
|
+
overrides["rpc_endpoints"] = config.rpc_groups[default_group].endpoints
|
|
632
|
+
|
|
633
|
+
return replace(config, **overrides), overridden
|
brawny/config/routing.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""RPC group routing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from brawny.config import Config
|
|
9
|
+
from brawny.jobs.base import Job
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_default_group(config: "Config") -> str:
|
|
13
|
+
"""Resolve the default RPC group.
|
|
14
|
+
|
|
15
|
+
Rules:
|
|
16
|
+
- If rpc_default_group is set, use it.
|
|
17
|
+
- If exactly one rpc_group exists, use it.
|
|
18
|
+
- If multiple rpc_groups and no default, raise.
|
|
19
|
+
"""
|
|
20
|
+
if config.rpc_default_group:
|
|
21
|
+
return config.rpc_default_group
|
|
22
|
+
|
|
23
|
+
if len(config.rpc_groups) == 1:
|
|
24
|
+
return next(iter(config.rpc_groups.keys()))
|
|
25
|
+
|
|
26
|
+
if not config.rpc_groups:
|
|
27
|
+
raise ValueError("rpc_groups not configured; set rpc_groups and rpc_default_group")
|
|
28
|
+
|
|
29
|
+
raise ValueError("Multiple rpc_groups configured; set rpc_default_group.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_job_groups(config: "Config", job: "Job") -> tuple[str, str]:
|
|
33
|
+
"""Resolve read/broadcast groups for a job.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
(read_group, broadcast_group)
|
|
37
|
+
"""
|
|
38
|
+
read_group = getattr(job, "_read_group", None)
|
|
39
|
+
broadcast_group = getattr(job, "_broadcast_group", None)
|
|
40
|
+
|
|
41
|
+
if not config.rpc_groups:
|
|
42
|
+
raise ValueError("rpc_groups not configured; set rpc_groups and rpc_default_group")
|
|
43
|
+
|
|
44
|
+
default_group = resolve_default_group(config)
|
|
45
|
+
if read_group is None:
|
|
46
|
+
read_group = default_group
|
|
47
|
+
if broadcast_group is None:
|
|
48
|
+
broadcast_group = default_group
|
|
49
|
+
|
|
50
|
+
if read_group not in config.rpc_groups:
|
|
51
|
+
raise ValueError(f"read_group '{read_group}' not found in rpc_groups")
|
|
52
|
+
if broadcast_group not in config.rpc_groups:
|
|
53
|
+
raise ValueError(f"broadcast_group '{broadcast_group}' not found in rpc_groups")
|
|
54
|
+
|
|
55
|
+
return read_group, broadcast_group
|