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.
Files changed (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. brawny-0.1.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,269 @@
1
+ """Network configuration loading (Brownie-compatible).
2
+
3
+ Loads from:
4
+ 1. ~/.brawny/network-config.yaml (if exists)
5
+ 2. Auto-copies ~/.brownie/network-config.yaml to ~/.brawny/ on first use
6
+ 3. Built-in defaults
7
+
8
+ NOTE: This module provides Brownie-compatible ~/.brawny/network-config.yaml support.
9
+ Project-level config.yaml network sections are no longer supported.
10
+ They are in separate namespaces and serve different purposes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import shutil
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import yaml
23
+
24
+
25
+ # Pattern to match ${VAR}, ${VAR:-default}, or $VAR forms (consistent with brawny.config)
26
+ _ENV_VAR_PATTERN = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}?")
27
+
28
+
29
+ class EnvVarExpansionError(ValueError):
30
+ """Raised when an environment variable cannot be expanded."""
31
+
32
+ def __init__(self, var_name: str, original_value: str, network_id: str | None = None):
33
+ self.var_name = var_name
34
+ self.original_value = original_value
35
+ self.network_id = network_id
36
+ network_ctx = f" in network '{network_id}'" if network_id else ""
37
+ super().__init__(
38
+ f"Environment variable '{var_name}' is not set{network_ctx}. "
39
+ f"Original value: {original_value}"
40
+ )
41
+
42
+
43
+ def _expand_env_var(value: str, *, network_id: str | None = None) -> str | None:
44
+ """Expand environment variables in a string.
45
+
46
+ Supports:
47
+ - $VAR or ${VAR} - replaced with env var value
48
+ - ${VAR:-default} - replaced with env var value, or default if not set
49
+
50
+ Returns:
51
+ - None if value is purely an env var that wasn't set (allows filtering)
52
+ - Expanded string otherwise
53
+
54
+ Raises:
55
+ - EnvVarExpansionError if a var inside a larger string is unset (partial expansion)
56
+ e.g., "https://alchemy.com/v2/$KEY" with KEY unset is an error, not a silent "/v2/"
57
+ """
58
+ unset_vars: list[str] = []
59
+
60
+ def replacer(match: re.Match[str]) -> str:
61
+ var_name = match.group(1)
62
+ default_val = match.group(2) # None if no default specified
63
+ env_val = os.environ.get(var_name)
64
+ if env_val is not None:
65
+ return env_val
66
+ if default_val is not None:
67
+ return default_val
68
+ # Track unset vars without defaults
69
+ unset_vars.append(var_name)
70
+ return ""
71
+
72
+ result = _ENV_VAR_PATTERN.sub(replacer, value)
73
+
74
+ # If the ENTIRE string was a single env var that wasn't set → return None (filterable)
75
+ if result == "" and len(unset_vars) == 1 and value.strip() in (f"${unset_vars[0]}", f"${{{unset_vars[0]}}}"):
76
+ return None
77
+
78
+ # If ANY env var was unset inside a larger string → error (partial expansion is dangerous)
79
+ if unset_vars:
80
+ raise EnvVarExpansionError(unset_vars[0], value, network_id)
81
+
82
+ return result
83
+
84
+
85
+ @dataclass
86
+ class NetworkConfig:
87
+ """Configuration for a single network (Brownie-compatible format)."""
88
+
89
+ id: str
90
+ hosts: list[str] # Flexible: accepts string or list in config, stored as list
91
+ chainid: int | None = None
92
+ name: str | None = None
93
+ explorer: str | None = None
94
+ multicall2: str | None = None # Passed to Contract layer for batch calls
95
+ timeout: int = 30
96
+
97
+ # RPC settings (passed to RPCManager for production-grade handling)
98
+ max_retries: int = 3
99
+ retry_backoff_base: float = 1.0
100
+ circuit_breaker_seconds: int = 300
101
+ rate_limit_per_second: float | None = None
102
+ rate_limit_burst: int | None = None
103
+
104
+ # Development network fields (None for live networks)
105
+ cmd: str | None = None
106
+ cmd_settings: dict[str, Any] = field(default_factory=dict)
107
+
108
+ @property
109
+ def is_development(self) -> bool:
110
+ return self.cmd is not None
111
+
112
+ @property
113
+ def is_fork(self) -> bool:
114
+ return self.cmd_settings.get("fork") is not None
115
+
116
+ def get_endpoints(self) -> list[str]:
117
+ """Get RPC endpoints with env var expansion.
118
+
119
+ - Pure env var hosts (e.g., "$BACKUP_RPC") are filtered if unset
120
+ - Partial expansion (e.g., "https://alchemy.com/v2/$KEY" with KEY unset) raises error
121
+
122
+ Raises:
123
+ EnvVarExpansionError: If a partial env var expansion would create invalid URL
124
+ ValueError: If no valid endpoints remain after expansion
125
+ """
126
+ endpoints = []
127
+ for host in self.hosts:
128
+ expanded = _expand_env_var(host, network_id=self.id)
129
+ if expanded: # Skip None (pure env var that wasn't set)
130
+ endpoints.append(expanded)
131
+
132
+ if not endpoints:
133
+ raise ValueError(
134
+ f"Network '{self.id}' has no valid RPC endpoints after env var expansion. "
135
+ f"Original hosts: {self.hosts}"
136
+ )
137
+ return endpoints
138
+
139
+ def get_host_with_port(self) -> str:
140
+ """Get first host URL with port for dev networks.
141
+
142
+ Uses urllib.parse to correctly detect if port is already in URL.
143
+ e.g., "http://127.0.0.1" needs port appended, "http://127.0.0.1:8545" doesn't.
144
+ """
145
+ from urllib.parse import urlparse
146
+
147
+ endpoints = self.get_endpoints()
148
+ host = endpoints[0]
149
+
150
+ # Only append port for dev networks without explicit port
151
+ if self.is_development:
152
+ parsed = urlparse(host)
153
+ if not parsed.port: # No explicit port in URL
154
+ port = self.cmd_settings.get("port", 8545)
155
+ # Reconstruct URL with port
156
+ host = f"{parsed.scheme}://{parsed.hostname}:{port}{parsed.path}"
157
+
158
+ return host
159
+
160
+
161
+ def _get_config_path() -> Path | None:
162
+ """Get network config file path. Auto-copies brownie config on first use."""
163
+ brawny_config = Path.home() / ".brawny" / "network-config.yaml"
164
+
165
+ if brawny_config.exists():
166
+ return brawny_config
167
+
168
+ # Auto-copy from brownie on first use
169
+ brownie_config = Path.home() / ".brownie" / "network-config.yaml"
170
+ if brownie_config.exists():
171
+ brawny_config.parent.mkdir(parents=True, exist_ok=True)
172
+ shutil.copy(brownie_config, brawny_config)
173
+ return brawny_config
174
+
175
+ return None
176
+
177
+
178
+ def _parse_host(host_value: str | list | None, default: str = "") -> list[str]:
179
+ """Parse host field - accepts string or list, returns list."""
180
+ if host_value is None:
181
+ return [default] if default else []
182
+ if isinstance(host_value, str):
183
+ return [host_value] if host_value else []
184
+ return list(host_value)
185
+
186
+
187
+ def _parse_networks(data: dict) -> dict[str, NetworkConfig]:
188
+ """Parse network config file."""
189
+ networks: dict[str, NetworkConfig] = {}
190
+
191
+ # Parse live networks
192
+ for group in data.get("live", []):
193
+ for net in group.get("networks", []):
194
+ networks[net["id"]] = NetworkConfig(
195
+ id=net["id"],
196
+ hosts=_parse_host(net.get("host")),
197
+ chainid=int(net["chainid"]) if "chainid" in net else None,
198
+ name=net.get("name"),
199
+ explorer=net.get("explorer"),
200
+ multicall2=net.get("multicall2"),
201
+ timeout=net.get("timeout", 30),
202
+ max_retries=net.get("max_retries", 3),
203
+ retry_backoff_base=net.get("retry_backoff_base", 1.0),
204
+ circuit_breaker_seconds=net.get("circuit_breaker_seconds", 300),
205
+ rate_limit_per_second=net.get("rate_limit_per_second"),
206
+ rate_limit_burst=net.get("rate_limit_burst"),
207
+ )
208
+
209
+ # Parse development networks (fork resolution happens at connect time)
210
+ for net in data.get("development", []):
211
+ networks[net["id"]] = NetworkConfig(
212
+ id=net["id"],
213
+ hosts=_parse_host(net.get("host"), default="http://127.0.0.1"),
214
+ name=net.get("name"),
215
+ timeout=net.get("timeout", 120),
216
+ cmd=net.get("cmd"),
217
+ cmd_settings=dict(net.get("cmd_settings", {})),
218
+ )
219
+
220
+ return networks
221
+
222
+
223
+ def load_networks() -> dict[str, NetworkConfig]:
224
+ """Load all network configurations from Brownie-style config."""
225
+ networks: dict[str, NetworkConfig] = {}
226
+
227
+ # Load from config file
228
+ config_path = _get_config_path()
229
+ if config_path:
230
+ try:
231
+ with open(config_path) as f:
232
+ data = yaml.safe_load(f) or {}
233
+ networks = _parse_networks(data)
234
+ except yaml.YAMLError as e:
235
+ raise ValueError(f"Invalid network config at {config_path}: {e}")
236
+
237
+ # Add built-in defaults (only if not already defined)
238
+ _add_defaults(networks)
239
+
240
+ return networks
241
+
242
+
243
+ def _add_defaults(networks: dict[str, NetworkConfig]) -> None:
244
+ """Add built-in defaults if not already present."""
245
+ if "development" not in networks:
246
+ networks["development"] = NetworkConfig(
247
+ id="development",
248
+ name="Anvil (Local)",
249
+ hosts=["http://127.0.0.1"],
250
+ cmd="anvil",
251
+ cmd_settings={"port": 8545, "accounts": 10},
252
+ )
253
+
254
+ if "mainnet-fork" not in networks:
255
+ # Use env var for fork URL with public fallback
256
+ # Users should set BRAWNY_FORK_RPC for reliable/fast forking
257
+ fork_url = "${BRAWNY_FORK_RPC:-https://eth.llamarpc.com}"
258
+ networks["mainnet-fork"] = NetworkConfig(
259
+ id="mainnet-fork",
260
+ name="Anvil (Mainnet Fork)",
261
+ hosts=["http://127.0.0.1"],
262
+ cmd="anvil",
263
+ timeout=120,
264
+ cmd_settings={
265
+ "port": 8546,
266
+ "fork": fork_url, # Expanded at connect time
267
+ "accounts": 10,
268
+ },
269
+ )
@@ -0,0 +1,423 @@
1
+ """Network connection management.
2
+
3
+ Provides Brownie-compatible network.connect()/disconnect() API.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import atexit
9
+ import json
10
+ import os
11
+ import shutil
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ from brawny.networks.config import NetworkConfig, load_networks
20
+
21
+ if TYPE_CHECKING:
22
+ from brawny._rpc import RPCManager
23
+
24
+
25
+ def _get_pidfile_dir() -> Path:
26
+ """Get directory for PID files."""
27
+ path = Path.home() / ".brawny" / "pids"
28
+ path.mkdir(parents=True, exist_ok=True)
29
+ return path
30
+
31
+
32
+ class NetworkManager:
33
+ """Manages network connections with Brownie-compatible API.
34
+
35
+ Usage:
36
+ from brawny import network
37
+
38
+ network.connect("mainnet")
39
+ print(network.show_active()) # "mainnet"
40
+ print(network.chain_id) # 1
41
+ network.disconnect()
42
+
43
+ Dev network lifecycle:
44
+ - PID files stored in ~/.brawny/pids/{network_id}.json
45
+ - Processes cleaned up on disconnect() or atexit
46
+ - Handles already-dead processes gracefully
47
+ """
48
+
49
+ def __init__(self) -> None:
50
+ self._networks: dict[str, NetworkConfig] | None = None
51
+ self._active: NetworkConfig | None = None
52
+ self._rpc: RPCManager | None = None
53
+ self._rpc_process: subprocess.Popen | None = None
54
+ self._rpc_process_network_id: str | None = None # Track which network we started
55
+ self._chain_id: int | None = None # Cached after first lookup
56
+
57
+ # Register cleanup on exit (handles normal exit, not SIGKILL)
58
+ atexit.register(self._cleanup)
59
+
60
+ # Handle SIGTERM gracefully (e.g., from container orchestration)
61
+ if sys.platform != "win32":
62
+ signal.signal(signal.SIGTERM, self._signal_cleanup)
63
+
64
+ def _signal_cleanup(self, signum: int, frame: object) -> None:
65
+ """Handle SIGTERM by cleaning up and re-raising."""
66
+ self._cleanup()
67
+ # Re-raise to allow normal signal handling
68
+ signal.signal(signum, signal.SIG_DFL)
69
+ os.kill(os.getpid(), signum)
70
+
71
+ def _cleanup(self) -> None:
72
+ """Kill RPC process on exit. Safe to call multiple times."""
73
+ if self._rpc_process is None:
74
+ return
75
+
76
+ # Check if still running (poll() returns None if running)
77
+ if self._rpc_process.poll() is None:
78
+ self._rpc_process.terminate()
79
+ try:
80
+ self._rpc_process.wait(timeout=3)
81
+ except subprocess.TimeoutExpired:
82
+ self._rpc_process.kill()
83
+ self._rpc_process.wait(timeout=1)
84
+
85
+ # Clean up PID file
86
+ if self._rpc_process_network_id:
87
+ self._remove_pidfile(self._rpc_process_network_id)
88
+ self._rpc_process_network_id = None
89
+
90
+ self._rpc_process = None
91
+
92
+ def _write_pidfile(self, network_id: str, pid: int, port: int) -> None:
93
+ """Write PID file for debugging."""
94
+ pidfile = _get_pidfile_dir() / f"{network_id}.json"
95
+ pidfile.write_text(json.dumps({"pid": pid, "port": port, "network_id": network_id}))
96
+
97
+ def _remove_pidfile(self, network_id: str) -> None:
98
+ """Remove PID file."""
99
+ pidfile = _get_pidfile_dir() / f"{network_id}.json"
100
+ pidfile.unlink(missing_ok=True)
101
+
102
+ def _ensure_networks(self) -> dict[str, NetworkConfig]:
103
+ """Lazily load networks."""
104
+ if self._networks is None:
105
+ self._networks = load_networks()
106
+ return self._networks
107
+
108
+ @property
109
+ def is_connected(self) -> bool:
110
+ """Check if connected to a network."""
111
+ return self._active is not None and self._rpc is not None
112
+
113
+ @property
114
+ def chain_id(self) -> int | None:
115
+ """Get current chain ID (cached)."""
116
+ if not self.is_connected or self._rpc is None:
117
+ return None
118
+ if self._chain_id is None:
119
+ self._chain_id = self._rpc.get_chain_id()
120
+ return self._chain_id
121
+
122
+ def show_active(self) -> str | None:
123
+ """Get ID of currently active network."""
124
+ return self._active.id if self._active else None
125
+
126
+ def connect(
127
+ self,
128
+ network_id: str | None = None,
129
+ launch_rpc: bool = True,
130
+ ) -> None:
131
+ """Connect to a network.
132
+
133
+ Args:
134
+ network_id: Network ID to connect to. Priority:
135
+ 1. Explicit argument
136
+ 2. BRAWNY_NETWORK env var
137
+ 3. "development" default
138
+ launch_rpc: If True, launch RPC process for development networks
139
+
140
+ Raises:
141
+ ConnectionError: If already connected
142
+ KeyError: If network_id not found
143
+ """
144
+ if self.is_connected:
145
+ raise ConnectionError(
146
+ f"Already connected to '{self._active.id}'. "
147
+ "Call network.disconnect() first."
148
+ )
149
+
150
+ networks = self._ensure_networks()
151
+
152
+ # Priority: explicit arg > env var > default
153
+ network_id = network_id or os.environ.get("BRAWNY_NETWORK") or "development"
154
+
155
+ if network_id not in networks:
156
+ available = ", ".join(sorted(networks.keys()))
157
+ raise KeyError(f"Network '{network_id}' not found. Available: {available}")
158
+
159
+ config = networks[network_id]
160
+
161
+ # Resolve fork reference at connect time
162
+ self._resolve_fork(config, networks)
163
+
164
+ # Get endpoints (list) - RPCManager handles failover automatically
165
+ endpoints = config.get_endpoints()
166
+
167
+ # Launch RPC for development networks
168
+ if config.is_development and launch_rpc:
169
+ local_url = config.get_host_with_port()
170
+ try:
171
+ self._launch_rpc(config)
172
+ self._wait_for_rpc(local_url, timeout=config.timeout)
173
+ except Exception:
174
+ # Clean up on failure
175
+ if self._rpc_process:
176
+ self._rpc_process.terminate()
177
+ self._rpc_process = None
178
+ raise
179
+ # Dev networks only use local endpoint
180
+ endpoints = [local_url]
181
+
182
+ # Create RPCManager with full configuration (preserves brawny's advantages)
183
+ from brawny._rpc import RPCManager
184
+
185
+ self._rpc = RPCManager(
186
+ endpoints=endpoints,
187
+ timeout_seconds=float(config.timeout),
188
+ max_retries=config.max_retries,
189
+ retry_backoff_base=config.retry_backoff_base,
190
+ circuit_breaker_seconds=config.circuit_breaker_seconds,
191
+ rate_limit_per_second=config.rate_limit_per_second,
192
+ rate_limit_burst=config.rate_limit_burst,
193
+ chain_id=config.chainid,
194
+ )
195
+ self._active = config
196
+ self._chain_id = None # Reset cache
197
+
198
+ # Verify connection and cache chain_id
199
+ try:
200
+ self._chain_id = self._rpc.get_chain_id()
201
+ except Exception as e:
202
+ self._cleanup_on_failure()
203
+ raise ConnectionError(f"Failed to connect to {network_id}: {e}")
204
+
205
+ # Update config chainid if not set
206
+ if config.chainid is None:
207
+ config.chainid = self._chain_id
208
+
209
+ def _cleanup_on_failure(self) -> None:
210
+ """Clean up state after connection failure."""
211
+ if self._rpc_process:
212
+ self._rpc_process.terminate()
213
+ self._rpc_process = None
214
+ self._rpc = None
215
+ self._active = None
216
+ self._chain_id = None
217
+
218
+ def _resolve_fork(
219
+ self,
220
+ config: NetworkConfig,
221
+ networks: dict[str, NetworkConfig],
222
+ ) -> None:
223
+ """Resolve fork reference to actual URL at connect time.
224
+
225
+ Only inherits chain_id/explorer/multicall2 from forked network if not
226
+ explicitly set on the dev config. This allows intentional overrides
227
+ (e.g., testing with a different chain_id).
228
+ """
229
+ fork_ref = config.cmd_settings.get("fork")
230
+ if not fork_ref:
231
+ return
232
+
233
+ # If fork is a network ID, resolve to URL (uses first endpoint from list)
234
+ if fork_ref in networks:
235
+ live = networks[fork_ref]
236
+ endpoints = live.get_endpoints()
237
+ config.cmd_settings["fork"] = endpoints[0] if endpoints else fork_ref
238
+
239
+ # Only inherit properties if not explicitly set on dev config
240
+ if config.chainid is None:
241
+ config.chainid = live.chainid
242
+ if config.explorer is None:
243
+ config.explorer = live.explorer
244
+ if config.multicall2 is None:
245
+ config.multicall2 = live.multicall2
246
+
247
+ # Set chain_id in cmd_settings for Anvil (only if not already set)
248
+ if "chain_id" not in config.cmd_settings and config.chainid:
249
+ config.cmd_settings["chain_id"] = config.chainid
250
+
251
+ def disconnect(self, kill_rpc: bool = True) -> None:
252
+ """Disconnect from the current network.
253
+
254
+ Args:
255
+ kill_rpc: If True (default), terminate any RPC process we started.
256
+ Safe to call even if process already died.
257
+ """
258
+ if not self._active:
259
+ raise ConnectionError("Not connected to any network")
260
+
261
+ if kill_rpc and self._rpc_process:
262
+ # Safe termination - handles already-dead processes
263
+ if self._rpc_process.poll() is None:
264
+ self._rpc_process.terminate()
265
+ try:
266
+ self._rpc_process.wait(timeout=3)
267
+ except subprocess.TimeoutExpired:
268
+ self._rpc_process.kill()
269
+ self._rpc_process.wait(timeout=1)
270
+
271
+ # Clean up PID file
272
+ if self._rpc_process_network_id:
273
+ self._remove_pidfile(self._rpc_process_network_id)
274
+ self._rpc_process_network_id = None
275
+
276
+ self._rpc_process = None
277
+
278
+ self._rpc = None
279
+ self._active = None
280
+ self._chain_id = None
281
+
282
+ def _launch_rpc(self, config: NetworkConfig) -> None:
283
+ """Launch RPC process for development network.
284
+
285
+ Writes PID file to ~/.brawny/pids/{network_id}.json for debugging.
286
+ """
287
+ import socket
288
+
289
+ if config.cmd is None:
290
+ raise RuntimeError(f"Network '{config.id}' has no cmd configured")
291
+
292
+ # Check if port is available
293
+ port = config.cmd_settings.get("port", 8545)
294
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
295
+ if s.connect_ex(("127.0.0.1", port)) == 0:
296
+ raise RuntimeError(
297
+ f"Port {port} already in use. "
298
+ f"Use a different port in cmd_settings or stop the existing process."
299
+ )
300
+
301
+ # Check if command exists
302
+ cmd_name = config.cmd.split()[0]
303
+ if not shutil.which(cmd_name):
304
+ raise RuntimeError(f"RPC command '{cmd_name}' not found. Install it first.")
305
+
306
+ # Build command as list (avoids shell=True security issues)
307
+ cmd_parts = self._build_rpc_command(config)
308
+
309
+ # Platform-specific process group handling
310
+ kwargs: dict[str, object] = {
311
+ "stdout": subprocess.DEVNULL,
312
+ "stderr": subprocess.DEVNULL,
313
+ }
314
+ if sys.platform != "win32":
315
+ kwargs["start_new_session"] = True # Unix: create new process group
316
+
317
+ self._rpc_process = subprocess.Popen(cmd_parts, **kwargs)
318
+ self._rpc_process_network_id = config.id
319
+
320
+ # Write PID file for debugging
321
+ self._write_pidfile(config.id, self._rpc_process.pid, port)
322
+
323
+ def _build_rpc_command(self, config: NetworkConfig) -> list[str]:
324
+ """Build command list for RPC process."""
325
+ if config.cmd is None:
326
+ raise RuntimeError(f"Network '{config.id}' has no cmd configured")
327
+
328
+ # Split base command (handles "npx hardhat node" etc.)
329
+ cmd_parts = config.cmd.split()
330
+ settings = config.cmd_settings
331
+
332
+ if "anvil" in config.cmd.lower():
333
+ if "port" in settings:
334
+ cmd_parts.extend(["--port", str(settings["port"])])
335
+ if "fork" in settings:
336
+ # Use _expand_env_var for consistency with config loading
337
+ from brawny.networks.config import _expand_env_var
338
+ fork_url = _expand_env_var(str(settings["fork"]), network_id=config.id)
339
+ if fork_url:
340
+ cmd_parts.extend(["--fork-url", fork_url])
341
+ if "fork_block" in settings:
342
+ cmd_parts.extend(["--fork-block-number", str(settings["fork_block"])])
343
+ if "accounts" in settings:
344
+ cmd_parts.extend(["--accounts", str(settings["accounts"])])
345
+ if "balance" in settings:
346
+ cmd_parts.extend(["--balance", str(settings["balance"])])
347
+ if "chain_id" in settings:
348
+ cmd_parts.extend(["--chain-id", str(settings["chain_id"])])
349
+
350
+ elif "ganache" in config.cmd.lower():
351
+ if "port" in settings:
352
+ cmd_parts.extend(["--port", str(settings["port"])])
353
+ if "fork" in settings:
354
+ from brawny.networks.config import _expand_env_var
355
+ fork_url = _expand_env_var(str(settings["fork"]), network_id=config.id)
356
+ if fork_url:
357
+ cmd_parts.extend(["--fork", fork_url])
358
+ if "accounts" in settings:
359
+ cmd_parts.extend(["--accounts", str(settings["accounts"])])
360
+ if "chain_id" in settings:
361
+ cmd_parts.extend(["--chainId", str(settings["chain_id"])])
362
+
363
+ return cmd_parts
364
+
365
+ def _wait_for_rpc(self, host: str, timeout: int = 30) -> None:
366
+ """Wait for RPC to become responsive.
367
+
368
+ Uses httpx for consistency with existing console.py implementation.
369
+ """
370
+ import httpx
371
+
372
+ start = time.time()
373
+ while time.time() - start < timeout:
374
+ # Check if process died
375
+ if self._rpc_process and self._rpc_process.poll() is not None:
376
+ raise RuntimeError("RPC process exited unexpectedly")
377
+
378
+ try:
379
+ resp = httpx.post(
380
+ host,
381
+ json={
382
+ "jsonrpc": "2.0",
383
+ "method": "eth_blockNumber",
384
+ "params": [],
385
+ "id": 1,
386
+ },
387
+ timeout=2.0,
388
+ )
389
+ if resp.status_code == 200:
390
+ return
391
+ except httpx.RequestError:
392
+ pass
393
+ time.sleep(0.3)
394
+
395
+ raise TimeoutError(f"RPC at {host} not responding after {timeout}s")
396
+
397
+ def list_networks(self) -> dict[str, list[str]]:
398
+ """List all available networks grouped by type."""
399
+ networks = self._ensure_networks()
400
+ return {
401
+ "live": [n.id for n in networks.values() if not n.is_development],
402
+ "development": [n.id for n in networks.values() if n.is_development],
403
+ }
404
+
405
+ @property
406
+ def rpc(self) -> RPCManager | None:
407
+ """Get underlying RPCManager."""
408
+ return self._rpc
409
+
410
+
411
+ # Global singleton
412
+ _manager: NetworkManager | None = None
413
+
414
+
415
+ def _get_manager() -> NetworkManager:
416
+ """Get or create network manager singleton."""
417
+ global _manager
418
+ if _manager is None:
419
+ _manager = NetworkManager()
420
+ return _manager
421
+
422
+
423
+ __all__ = ["NetworkManager", "_get_manager"]