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
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Configuration validation for brawny.
|
|
2
|
+
|
|
3
|
+
Provides validation logic for config values and endpoint canonicalization.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from brawny.model.enums import KeystoreType
|
|
11
|
+
from brawny.model.errors import ConfigError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidEndpointError(Exception):
|
|
15
|
+
"""Raised when an endpoint URL is malformed."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def canonicalize_endpoint(url: str) -> str:
|
|
21
|
+
"""Normalize endpoint URL for consistent comparison.
|
|
22
|
+
|
|
23
|
+
Rules:
|
|
24
|
+
- Strip whitespace
|
|
25
|
+
- Require http or https scheme
|
|
26
|
+
- Require non-empty hostname
|
|
27
|
+
- Lowercase scheme and hostname
|
|
28
|
+
- Remove default ports (80/443)
|
|
29
|
+
- Remove trailing slash (except root)
|
|
30
|
+
- Drop query string (RPC endpoints don't use them)
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
InvalidEndpointError: If URL is missing scheme or hostname
|
|
34
|
+
"""
|
|
35
|
+
url = url.strip()
|
|
36
|
+
if not url:
|
|
37
|
+
raise InvalidEndpointError("Empty endpoint URL")
|
|
38
|
+
|
|
39
|
+
parsed = urlparse(url)
|
|
40
|
+
scheme = parsed.scheme.lower()
|
|
41
|
+
|
|
42
|
+
# Validate scheme
|
|
43
|
+
if scheme not in {"http", "https"}:
|
|
44
|
+
raise InvalidEndpointError(
|
|
45
|
+
f"Invalid endpoint '{url}': scheme must be http or https, got '{scheme or '(none)'}'"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Validate hostname
|
|
49
|
+
hostname = (parsed.hostname or "").lower()
|
|
50
|
+
if not hostname:
|
|
51
|
+
raise InvalidEndpointError(f"Invalid endpoint '{url}': missing hostname")
|
|
52
|
+
|
|
53
|
+
port = parsed.port
|
|
54
|
+
if (scheme == "https" and port == 443) or (scheme == "http" and port == 80):
|
|
55
|
+
port = None
|
|
56
|
+
|
|
57
|
+
# Preserve Basic Auth credentials if present (e.g., https://user:pass@host)
|
|
58
|
+
userinfo = ""
|
|
59
|
+
if parsed.username:
|
|
60
|
+
userinfo = parsed.username
|
|
61
|
+
if parsed.password:
|
|
62
|
+
userinfo += f":{parsed.password}"
|
|
63
|
+
userinfo += "@"
|
|
64
|
+
|
|
65
|
+
netloc = f"{userinfo}{hostname}:{port}" if port else f"{userinfo}{hostname}"
|
|
66
|
+
path = parsed.path.rstrip("/") if len(parsed.path) > 1 else ""
|
|
67
|
+
|
|
68
|
+
# Drop query string — RPC endpoints don't use them, and they cause
|
|
69
|
+
# accidental uniqueness (e.g., tracking params, cache busters)
|
|
70
|
+
return f"{scheme}://{netloc}{path}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def dedupe_preserve_order(endpoints: list[str]) -> list[str]:
|
|
74
|
+
"""Remove duplicates while preserving order."""
|
|
75
|
+
seen: set[str] = set()
|
|
76
|
+
result: list[str] = []
|
|
77
|
+
for ep in endpoints:
|
|
78
|
+
if ep not in seen:
|
|
79
|
+
seen.add(ep)
|
|
80
|
+
result.append(ep)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
REMOVED_FIELDS = {
|
|
85
|
+
"alerts_dx_enabled",
|
|
86
|
+
"allowed_signers",
|
|
87
|
+
"broadcast_rpc",
|
|
88
|
+
"broadcast_url",
|
|
89
|
+
"rpc_endpoints",
|
|
90
|
+
"deep_reorg_alert_enabled",
|
|
91
|
+
"etherscan_api_key",
|
|
92
|
+
"etherscan_api_url",
|
|
93
|
+
"job_modules",
|
|
94
|
+
"jobs_path",
|
|
95
|
+
"log_level",
|
|
96
|
+
"log_format",
|
|
97
|
+
"metrics_enabled",
|
|
98
|
+
"metrics_bind",
|
|
99
|
+
"networks",
|
|
100
|
+
"signers",
|
|
101
|
+
"sourcify_enabled",
|
|
102
|
+
"strict_job_validation",
|
|
103
|
+
"telegram_chat_ids",
|
|
104
|
+
"webhook_url",
|
|
105
|
+
"block_hash_history_size",
|
|
106
|
+
"brownie_password_fallback",
|
|
107
|
+
"claim_timeout_seconds",
|
|
108
|
+
"db_circuit_breaker_failures",
|
|
109
|
+
"db_circuit_breaker_seconds",
|
|
110
|
+
"deep_reorg_pause",
|
|
111
|
+
"intent_retry_backoff_seconds",
|
|
112
|
+
"rpc_circuit_breaker_seconds",
|
|
113
|
+
"shutdown_grace_seconds",
|
|
114
|
+
"shutdown_timeout_seconds",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_no_removed_fields(raw_config: dict) -> None:
|
|
119
|
+
"""Fail fast if removed config options are present."""
|
|
120
|
+
forbidden: set[str] = set()
|
|
121
|
+
for key in raw_config.keys():
|
|
122
|
+
if key in REMOVED_FIELDS:
|
|
123
|
+
forbidden.add(key)
|
|
124
|
+
|
|
125
|
+
advanced = raw_config.get("advanced")
|
|
126
|
+
if isinstance(advanced, dict):
|
|
127
|
+
for key in advanced.keys():
|
|
128
|
+
if key in REMOVED_FIELDS:
|
|
129
|
+
forbidden.add(key)
|
|
130
|
+
|
|
131
|
+
if forbidden:
|
|
132
|
+
raise ConfigError(
|
|
133
|
+
"Removed config options detected: "
|
|
134
|
+
f"{sorted(forbidden)}. These options no longer exist."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def validate_config(config: "Config") -> None:
|
|
139
|
+
"""Validate all configuration values.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
config: Config instance to validate
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ConfigError: If validation fails
|
|
146
|
+
"""
|
|
147
|
+
from brawny.config.models import Config
|
|
148
|
+
|
|
149
|
+
errors: list[str] = []
|
|
150
|
+
|
|
151
|
+
# Required fields
|
|
152
|
+
if not config.database_url:
|
|
153
|
+
errors.append("database_url is required")
|
|
154
|
+
elif not (
|
|
155
|
+
config.database_url.startswith("postgresql://")
|
|
156
|
+
or config.database_url.startswith("postgres://")
|
|
157
|
+
or config.database_url.startswith("sqlite:///")
|
|
158
|
+
):
|
|
159
|
+
errors.append(
|
|
160
|
+
"database_url must start with postgresql://, postgres://, or sqlite:///"
|
|
161
|
+
)
|
|
162
|
+
elif config.database_url.startswith("sqlite:///") and config.worker_count > 1:
|
|
163
|
+
errors.append("SQLite does not support worker_count > 1. Use Postgres for production.")
|
|
164
|
+
|
|
165
|
+
if not config.rpc_groups:
|
|
166
|
+
errors.append("rpc_groups is required (at least one group)")
|
|
167
|
+
|
|
168
|
+
if config.chain_id <= 0:
|
|
169
|
+
errors.append("chain_id must be positive")
|
|
170
|
+
|
|
171
|
+
# RPC Groups validation
|
|
172
|
+
if config.rpc_groups:
|
|
173
|
+
# All groups must have at least one endpoint
|
|
174
|
+
for name, group in config.rpc_groups.items():
|
|
175
|
+
if not group.endpoints:
|
|
176
|
+
errors.append(f"rpc_groups.{name} has no endpoints")
|
|
177
|
+
|
|
178
|
+
if config.rpc_default_group:
|
|
179
|
+
if config.rpc_default_group not in config.rpc_groups:
|
|
180
|
+
errors.append(
|
|
181
|
+
f"rpc_default_group '{config.rpc_default_group}' not found in rpc_groups"
|
|
182
|
+
)
|
|
183
|
+
elif len(config.rpc_groups) > 1:
|
|
184
|
+
errors.append(
|
|
185
|
+
"Multiple rpc_groups configured; set rpc_default_group to avoid ambiguity"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if config.worker_count <= 0:
|
|
189
|
+
errors.append("worker_count must be positive")
|
|
190
|
+
|
|
191
|
+
# Keystore validation
|
|
192
|
+
if config.keystore_type == KeystoreType.FILE and not config.keystore_path:
|
|
193
|
+
errors.append("keystore_path is required when keystore_type is 'file'")
|
|
194
|
+
|
|
195
|
+
if errors:
|
|
196
|
+
raise ConfigError(
|
|
197
|
+
"Configuration validation failed:\n" + "\n".join(f" - {e}" for e in errors)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def validate_advanced_config(advanced: "AdvancedConfig") -> None:
|
|
202
|
+
"""Validate advanced configuration values."""
|
|
203
|
+
from brawny.config.models import AdvancedConfig
|
|
204
|
+
|
|
205
|
+
if not isinstance(advanced, AdvancedConfig):
|
|
206
|
+
raise ConfigError("advanced config must be an AdvancedConfig instance")
|
|
207
|
+
|
|
208
|
+
errors: list[str] = []
|
|
209
|
+
|
|
210
|
+
if advanced.poll_interval_seconds <= 0:
|
|
211
|
+
errors.append("poll_interval_seconds must be positive")
|
|
212
|
+
if advanced.reorg_depth <= 0:
|
|
213
|
+
errors.append("reorg_depth must be positive")
|
|
214
|
+
if advanced.finality_confirmations < 0:
|
|
215
|
+
errors.append("finality_confirmations must be non-negative")
|
|
216
|
+
if advanced.default_deadline_seconds <= 0:
|
|
217
|
+
errors.append("default_deadline_seconds must be positive")
|
|
218
|
+
if advanced.stuck_tx_seconds <= 0:
|
|
219
|
+
errors.append("stuck_tx_seconds must be positive")
|
|
220
|
+
if advanced.max_replacement_attempts < 0:
|
|
221
|
+
errors.append("max_replacement_attempts cannot be negative")
|
|
222
|
+
|
|
223
|
+
if advanced.gas_limit_multiplier < 1.0:
|
|
224
|
+
errors.append("gas_limit_multiplier must be at least 1.0")
|
|
225
|
+
if advanced.default_priority_fee_gwei < 0:
|
|
226
|
+
errors.append("default_priority_fee_gwei must be non-negative")
|
|
227
|
+
if advanced.max_fee_cap_gwei is not None and advanced.max_fee_cap_gwei <= 0:
|
|
228
|
+
errors.append("max_fee_cap_gwei must be positive when set")
|
|
229
|
+
if advanced.fee_bump_percent < 10:
|
|
230
|
+
errors.append("fee_bump_percent must be at least 10 (Ethereum protocol minimum)")
|
|
231
|
+
|
|
232
|
+
if advanced.rpc_timeout_seconds <= 0:
|
|
233
|
+
errors.append("rpc_timeout_seconds must be positive")
|
|
234
|
+
if advanced.rpc_max_retries < 0:
|
|
235
|
+
errors.append("rpc_max_retries cannot be negative")
|
|
236
|
+
|
|
237
|
+
if advanced.database_pool_size <= 0:
|
|
238
|
+
errors.append("database_pool_size must be positive")
|
|
239
|
+
if advanced.database_pool_max_overflow < 0:
|
|
240
|
+
errors.append("database_pool_max_overflow cannot be negative")
|
|
241
|
+
|
|
242
|
+
if errors:
|
|
243
|
+
raise ConfigError(
|
|
244
|
+
"Advanced configuration validation failed:\n"
|
|
245
|
+
+ "\n".join(f" - {e}" for e in errors)
|
|
246
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Daemon orchestration for brawny.
|
|
2
|
+
|
|
3
|
+
Provides the main BrawnyDaemon class for running the job executor.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from brawny.daemon.context import DaemonContext, DaemonState, RuntimeOverrides
|
|
7
|
+
from brawny.daemon.core import BrawnyDaemon
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BrawnyDaemon",
|
|
11
|
+
"DaemonContext",
|
|
12
|
+
"DaemonState",
|
|
13
|
+
"RuntimeOverrides",
|
|
14
|
+
]
|
brawny/daemon/context.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Daemon context and state for brawny.
|
|
2
|
+
|
|
3
|
+
Provides DaemonContext (shared component references) and DaemonState (loop callbacks).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from logging import Logger
|
|
10
|
+
from typing import TYPE_CHECKING, Callable
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from brawny.config import Config
|
|
14
|
+
from brawny.db.base import Database
|
|
15
|
+
from brawny._rpc.manager import RPCManager
|
|
16
|
+
from brawny.tx.executor import TxExecutor
|
|
17
|
+
from brawny.tx.monitor import TxMonitor
|
|
18
|
+
from brawny.tx.replacement import TxReplacer
|
|
19
|
+
from brawny.tx.nonce import NonceManager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DaemonContext:
|
|
24
|
+
"""Shared context for daemon loops.
|
|
25
|
+
|
|
26
|
+
Contains references to all components needed by worker and monitor loops.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
config: "Config"
|
|
30
|
+
log: Logger
|
|
31
|
+
db: "Database"
|
|
32
|
+
rpc: "RPCManager"
|
|
33
|
+
executor: "TxExecutor | None"
|
|
34
|
+
monitor: "TxMonitor | None"
|
|
35
|
+
replacer: "TxReplacer | None"
|
|
36
|
+
nonce_manager: "NonceManager | None"
|
|
37
|
+
chain_id: int
|
|
38
|
+
|
|
39
|
+
# Health alerts (optional - None means disabled)
|
|
40
|
+
health_send_fn: Callable[..., None] | None = None
|
|
41
|
+
health_chat_id: str | None = None
|
|
42
|
+
health_cooldown: int = 1800
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class DaemonState:
|
|
47
|
+
"""State callbacks for daemon loops.
|
|
48
|
+
|
|
49
|
+
Provides callbacks to track inflight operations and generate claim tokens.
|
|
50
|
+
Keeps loops decoupled from daemon internals.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
make_claim_token: Callable[[int], str]
|
|
54
|
+
make_claimed_by: Callable[[int], str]
|
|
55
|
+
inflight_inc: Callable[[], None]
|
|
56
|
+
inflight_dec: Callable[[], None]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class RuntimeOverrides:
|
|
61
|
+
"""Runtime overrides for daemon configuration.
|
|
62
|
+
|
|
63
|
+
Allows CLI and programmatic callers to override config values.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
dry_run: bool = False
|
|
67
|
+
once: bool = False
|
|
68
|
+
worker_count: int | None = None
|
|
69
|
+
strict_validation: bool = True
|