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,156 @@
1
+ """Endpoint selection with health-aware ordering.
2
+
3
+ This module extracts endpoint health tracking and selection from RPCManager,
4
+ following OE6's separation of concerns.
5
+
6
+ INVARIANT: order_endpoints() always returns ALL endpoints, just ordered.
7
+ Unhealthy endpoints are moved to the end, not removed. This ensures
8
+ recovered endpoints eventually get tried again.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from dataclasses import dataclass, field
15
+
16
+
17
+ @dataclass
18
+ class EndpointHealth:
19
+ """Health tracking for a single RPC endpoint."""
20
+
21
+ url: str
22
+ consecutive_failures: int = 0
23
+ last_success_ts: float | None = None
24
+ last_failure_ts: float | None = None
25
+ latency_ewma_ms: float = 100.0 # Start with reasonable default
26
+
27
+ # EWMA smoothing factor (0.3 = 30% weight to new samples, more responsive than old 0.1)
28
+ EWMA_ALPHA: float = 0.3
29
+
30
+ @property
31
+ def is_healthy(self) -> bool:
32
+ """Check if endpoint is currently healthy (below failure threshold)."""
33
+ # Threshold is managed by EndpointSelector
34
+ return True # Selector determines health based on threshold
35
+
36
+ def record_success(self, latency_ms: float) -> None:
37
+ """Record a successful RPC call.
38
+
39
+ Args:
40
+ latency_ms: Request latency in milliseconds
41
+ """
42
+ self.consecutive_failures = 0
43
+ self.last_success_ts = time.time()
44
+ # EWMA update
45
+ self.latency_ewma_ms = (
46
+ self.EWMA_ALPHA * latency_ms + (1 - self.EWMA_ALPHA) * self.latency_ewma_ms
47
+ )
48
+
49
+ def record_failure(self) -> None:
50
+ """Record a failed RPC call (transport-class failures only)."""
51
+ self.consecutive_failures += 1
52
+ self.last_failure_ts = time.time()
53
+
54
+
55
+ class EndpointSelector:
56
+ """Health-aware endpoint selection.
57
+
58
+ CONSTRAINTS (to prevent scope creep):
59
+ - Only track consecutive failures + EWMA latency
60
+ - No background probing
61
+ - No partial circuit breaker logic
62
+ - No complex health scoring
63
+
64
+ INVARIANT: order_endpoints() always returns ALL endpoints, just ordered.
65
+ Unhealthy endpoints are moved to the end, not removed. This ensures
66
+ recovered endpoints eventually get tried again.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ endpoints: list[str],
72
+ failure_threshold: int = 3,
73
+ ) -> None:
74
+ """Initialize endpoint selector.
75
+
76
+ Args:
77
+ endpoints: List of endpoint URLs
78
+ failure_threshold: Consecutive failures before endpoint is unhealthy
79
+ """
80
+ if not endpoints:
81
+ raise ValueError("At least one endpoint is required")
82
+
83
+ self._endpoints = [EndpointHealth(url=url.strip()) for url in endpoints if url.strip()]
84
+ if not self._endpoints:
85
+ raise ValueError("At least one non-empty endpoint is required")
86
+
87
+ self._failure_threshold = failure_threshold
88
+ self._endpoint_map: dict[str, EndpointHealth] = {e.url: e for e in self._endpoints}
89
+
90
+ @property
91
+ def endpoints(self) -> list[EndpointHealth]:
92
+ """Get all endpoint health objects."""
93
+ return self._endpoints
94
+
95
+ def get_endpoint(self, url: str) -> EndpointHealth | None:
96
+ """Get endpoint health by URL."""
97
+ return self._endpoint_map.get(url)
98
+
99
+ def is_healthy(self, endpoint: EndpointHealth) -> bool:
100
+ """Check if an endpoint is healthy (below failure threshold)."""
101
+ return endpoint.consecutive_failures < self._failure_threshold
102
+
103
+ def has_healthy_endpoint(self) -> bool:
104
+ """Check if any endpoint is healthy."""
105
+ return any(self.is_healthy(e) for e in self._endpoints)
106
+
107
+ def order_endpoints(self) -> list[EndpointHealth]:
108
+ """Return ALL endpoints ordered by health, preserving position priority.
109
+
110
+ Ordering:
111
+ 1. Healthy endpoints in original order (first = primary)
112
+ 2. Unhealthy endpoints in original order
113
+
114
+ Position-based: First healthy endpoint in user config is always preferred.
115
+ """
116
+ healthy = [e for e in self._endpoints if self.is_healthy(e)]
117
+ unhealthy = [e for e in self._endpoints if not self.is_healthy(e)]
118
+ return healthy + unhealthy
119
+
120
+ def get_active_endpoint(self) -> EndpointHealth:
121
+ """Get the preferred endpoint (healthiest first).
122
+
123
+ Returns first healthy endpoint. If no healthy endpoints,
124
+ returns least recently failed.
125
+
126
+ Recovery: When an endpoint's consecutive_failures resets to 0 via
127
+ record_success(), it becomes healthy and can be returned again.
128
+ """
129
+ ordered = self.order_endpoints()
130
+ if ordered:
131
+ return ordered[0]
132
+ # Fallback (should not happen if endpoints exist)
133
+ return self._endpoints[0]
134
+
135
+ def record_success(self, url: str, latency_ms: float) -> None:
136
+ """Record successful call for an endpoint.
137
+
138
+ Args:
139
+ url: Endpoint URL
140
+ latency_ms: Request latency in milliseconds
141
+ """
142
+ endpoint = self._endpoint_map.get(url)
143
+ if endpoint:
144
+ endpoint.record_success(latency_ms)
145
+
146
+ def record_failure(self, url: str) -> None:
147
+ """Record failed call for an endpoint (transport-class failures only).
148
+
149
+ Only call this for RPCRetryableError, not for Fatal/Recoverable errors.
150
+
151
+ Args:
152
+ url: Endpoint URL
153
+ """
154
+ endpoint = self._endpoint_map.get(url)
155
+ if endpoint:
156
+ endpoint.record_failure()
brawny/accounts.py ADDED
@@ -0,0 +1,534 @@
1
+ """Brownie-compatible accounts management.
2
+
3
+ Storage: ~/.brownie/accounts/*.json (Brownie compatibility)
4
+ Format: Ethereum Keystore JSON v3 (Web3 Secret Storage)
5
+
6
+ Usage:
7
+ from brawny import accounts
8
+
9
+ # Load by name (prompts for password if needed)
10
+ acct = accounts.load("my_wallet")
11
+
12
+ # Add new account (generates mnemonic if no key provided)
13
+ acct = accounts.add() # Generates new key
14
+ acct = accounts.add("0x...") # From private key
15
+
16
+ # Save to keystore
17
+ acct.save("my_wallet")
18
+
19
+ # From mnemonic
20
+ acct = accounts.from_mnemonic("word1 word2 ...")
21
+
22
+ # Index access (loaded accounts)
23
+ accounts[0]
24
+
25
+ # List available (not yet loaded)
26
+ accounts.list() # Returns ["wallet1", "wallet2", ...]
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import getpass
32
+ import json
33
+ import os
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import TYPE_CHECKING, Any, Iterator
37
+
38
+ from eth_account import Account as EthAccount
39
+ from eth_account.hdaccount import generate_mnemonic
40
+
41
+ if TYPE_CHECKING:
42
+ from brawny.jobs.base import TxReceipt
43
+
44
+ _accounts: "Accounts | None" = None
45
+
46
+
47
+ def _get_accounts_dir() -> Path:
48
+ """Get accounts directory (Brownie-compatible default)."""
49
+ # User override
50
+ if env_dir := os.environ.get("ETHJ_ACCOUNTS_DIR"):
51
+ return Path(env_dir).expanduser()
52
+
53
+ # Default: Brownie location for compatibility
54
+ brownie_dir = Path.home() / ".brownie" / "accounts"
55
+ if brownie_dir.exists():
56
+ return brownie_dir
57
+
58
+ # Fallback: brawny location
59
+ brawny_dir = Path.home() / ".brawny" / "accounts"
60
+ brawny_dir.mkdir(parents=True, exist_ok=True)
61
+ return brawny_dir
62
+
63
+
64
+ def _get_password(name: str, password: str | None = None) -> str:
65
+ """Resolve password for account.
66
+
67
+ Priority:
68
+ 1. Explicit password argument
69
+ 2. Environment variable ETHJ_PASSWORD_<NAME>
70
+ 3. Interactive prompt (if TTY)
71
+ 4. Error
72
+ """
73
+ if password is not None:
74
+ return password
75
+
76
+ # Environment variable
77
+ env_key = f"ETHJ_PASSWORD_{name.upper()}"
78
+ if env_pass := os.environ.get(env_key):
79
+ return env_pass
80
+
81
+ # Interactive prompt
82
+ if sys.stdin.isatty():
83
+ return getpass.getpass(f"Enter password for '{name}': ")
84
+
85
+ raise ValueError(
86
+ f"No password for '{name}'. Set {env_key} or provide password argument."
87
+ )
88
+
89
+
90
+ class Account:
91
+ """Represents a signing account.
92
+
93
+ Brownie-compatible interface for transaction signing.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ address: str,
99
+ private_key: bytes | None = None,
100
+ alias: str | None = None,
101
+ ) -> None:
102
+ self._address = address
103
+ self._private_key = private_key # Only set after unlock/add
104
+ self._alias = alias
105
+
106
+ @property
107
+ def address(self) -> str:
108
+ """Checksummed address."""
109
+ return self._address
110
+
111
+ @property
112
+ def alias(self) -> str | None:
113
+ """Keystore alias (e.g., 'worker', 'deployer')."""
114
+ return self._alias
115
+
116
+ @property
117
+ def private_key(self) -> str:
118
+ """Private key as hex string."""
119
+ if self._private_key is None:
120
+ raise ValueError(f"Account {self._address} is locked")
121
+ return "0x" + self._private_key.hex()
122
+
123
+ def balance(self) -> int:
124
+ """Get account balance in wei."""
125
+ from brawny.api import rpc
126
+ return rpc.get_balance(self._address)
127
+
128
+ def save(
129
+ self,
130
+ filename: str,
131
+ password: str | None = None,
132
+ overwrite: bool = False,
133
+ ) -> Path:
134
+ """Save account to keystore file.
135
+
136
+ Args:
137
+ filename: Name for keystore file (without .json extension)
138
+ password: Encryption password (prompts if not provided)
139
+ overwrite: Allow overwriting existing file
140
+
141
+ Returns:
142
+ Path to saved keystore file
143
+
144
+ Raises:
145
+ FileExistsError: If file exists and overwrite=False
146
+ """
147
+ if self._private_key is None:
148
+ raise ValueError("Cannot save locked account")
149
+
150
+ accounts_dir = _get_accounts_dir()
151
+ filepath = accounts_dir / f"{filename}.json"
152
+
153
+ if filepath.exists() and not overwrite:
154
+ raise FileExistsError(f"Account '{filename}' already exists. Use overwrite=True.")
155
+
156
+ # Get password
157
+ if password is None:
158
+ if sys.stdin.isatty():
159
+ password = getpass.getpass(f"Enter password for '{filename}': ")
160
+ confirm = getpass.getpass("Confirm password: ")
161
+ if password != confirm:
162
+ raise ValueError("Passwords do not match")
163
+ else:
164
+ raise ValueError("Password required for non-interactive save")
165
+
166
+ # Encrypt with standard Ethereum keystore format
167
+ keystore = EthAccount.encrypt(self._private_key, password)
168
+
169
+ # Write file
170
+ accounts_dir.mkdir(parents=True, exist_ok=True)
171
+ with open(filepath, "w") as f:
172
+ json.dump(keystore, f, indent=2)
173
+
174
+ self._alias = filename
175
+ return filepath
176
+
177
+ def transfer(
178
+ self,
179
+ to: str,
180
+ amount: int | str,
181
+ gas_limit: int | None = None,
182
+ gas_price: int | None = None,
183
+ max_fee_per_gas: int | None = None,
184
+ max_priority_fee_per_gas: int | None = None,
185
+ data: str | None = None,
186
+ ) -> "TxReceipt":
187
+ """Send ETH to an address."""
188
+ from brawny.api import Wei
189
+ from brawny.script_tx import _get_broadcaster
190
+
191
+ if isinstance(amount, str):
192
+ amount = Wei(amount)
193
+
194
+ return _get_broadcaster().transfer(
195
+ sender=self._address,
196
+ to=to,
197
+ value=amount,
198
+ gas_limit=gas_limit,
199
+ gas_price=gas_price,
200
+ max_fee_per_gas=max_fee_per_gas,
201
+ max_priority_fee_per_gas=max_priority_fee_per_gas,
202
+ data=data,
203
+ private_key=self._private_key,
204
+ )
205
+
206
+ def sign_transaction(self, tx: dict) -> Any:
207
+ """Sign a transaction dict."""
208
+ if self._private_key is None:
209
+ raise ValueError(f"Account {self._address} is locked")
210
+ return EthAccount.sign_transaction(tx, self._private_key)
211
+
212
+ def sign_message(self, message: str | bytes) -> Any:
213
+ """Sign a message."""
214
+ from eth_account.messages import encode_defunct
215
+
216
+ if self._private_key is None:
217
+ raise ValueError(f"Account {self._address} is locked")
218
+
219
+ if isinstance(message, str):
220
+ message = message.encode()
221
+
222
+ signable = encode_defunct(primitive=message)
223
+ return EthAccount.sign_message(signable, self._private_key)
224
+
225
+ def __repr__(self) -> str:
226
+ if self._alias:
227
+ return f"<Account '{self._alias}' {self._address}>"
228
+ return f"<Account {self._address}>"
229
+
230
+ def __str__(self) -> str:
231
+ return self._address
232
+
233
+ def __eq__(self, other: Any) -> bool:
234
+ if isinstance(other, Account):
235
+ return self._address.lower() == other._address.lower()
236
+ if isinstance(other, str):
237
+ return self._address.lower() == other.lower()
238
+ return False
239
+
240
+ def __hash__(self) -> int:
241
+ return hash(self._address.lower())
242
+
243
+
244
+ class Accounts:
245
+ """Container for available signing accounts.
246
+
247
+ Brownie-compatible interface:
248
+ accounts[0] # By index (loaded accounts)
249
+ accounts.load("wallet") # Load from keystore
250
+ accounts.add("0x...") # Add from private key
251
+ accounts.add() # Generate new account
252
+ accounts.from_mnemonic(...) # From BIP39 mnemonic
253
+ accounts.list() # List available keystores
254
+ for acc in accounts: ... # Iteration (loaded only)
255
+ len(accounts) # Count (loaded only)
256
+ """
257
+
258
+ def __init__(self) -> None:
259
+ self._loaded: list[Account] = []
260
+ self._by_address: dict[str, Account] = {}
261
+ self._by_alias: dict[str, Account] = {}
262
+ self.default: Account | None = None
263
+
264
+ def _register(self, account: Account) -> Account:
265
+ """Register account in container."""
266
+ self._loaded.append(account)
267
+ self._by_address[account.address.lower()] = account
268
+ if account.alias:
269
+ self._by_alias[account.alias.lower()] = account
270
+ return account
271
+
272
+ def list(self) -> list[str]:
273
+ """List available keystore names (not yet loaded).
274
+
275
+ Returns:
276
+ List of keystore names (without .json extension)
277
+ """
278
+ accounts_dir = _get_accounts_dir()
279
+ if not accounts_dir.exists():
280
+ return []
281
+ return [f.stem for f in accounts_dir.glob("*.json")]
282
+
283
+ def load(
284
+ self,
285
+ filename: str,
286
+ password: str | None = None,
287
+ ) -> Account:
288
+ """Load account from keystore file.
289
+
290
+ Args:
291
+ filename: Keystore name (without .json)
292
+ password: Decryption password (prompts if not provided)
293
+
294
+ Returns:
295
+ Unlocked Account instance
296
+
297
+ Raises:
298
+ FileNotFoundError: If keystore doesn't exist
299
+ ValueError: If password is wrong
300
+ """
301
+ # Check if already loaded
302
+ if filename.lower() in self._by_alias:
303
+ return self._by_alias[filename.lower()]
304
+
305
+ # Find keystore file
306
+ accounts_dir = _get_accounts_dir()
307
+ filepath = accounts_dir / f"{filename}.json"
308
+
309
+ if not filepath.exists():
310
+ # Also check Brownie dir if we're using brawny dir
311
+ brownie_path = Path.home() / ".brownie" / "accounts" / f"{filename}.json"
312
+ if brownie_path.exists():
313
+ filepath = brownie_path
314
+ else:
315
+ raise FileNotFoundError(f"Keystore '{filename}' not found")
316
+
317
+ # Load and decrypt
318
+ with open(filepath) as f:
319
+ keystore = json.load(f)
320
+
321
+ password = _get_password(filename, password)
322
+
323
+ try:
324
+ private_key = EthAccount.decrypt(keystore, password)
325
+ except ValueError as e:
326
+ raise ValueError(f"Wrong password for '{filename}'") from e
327
+
328
+ # Create account
329
+ eth_acct = EthAccount.from_key(private_key)
330
+ account = Account(
331
+ address=eth_acct.address,
332
+ private_key=private_key,
333
+ alias=filename,
334
+ )
335
+
336
+ return self._register(account)
337
+
338
+ def add(self, private_key: str | bytes | None = None) -> Account:
339
+ """Add account from private key or generate new one.
340
+
341
+ Args:
342
+ private_key: Optional private key (hex string or bytes).
343
+ If None, generates new account with mnemonic.
344
+
345
+ Returns:
346
+ Account instance (prints mnemonic if generated)
347
+ """
348
+ if private_key is None:
349
+ # Generate new account with mnemonic
350
+ mnemonic = generate_mnemonic(num_words=12, lang="english")
351
+ print(f"mnemonic: '{mnemonic}'")
352
+ eth_acct = EthAccount.from_mnemonic(mnemonic)
353
+ account = Account(
354
+ address=eth_acct.address,
355
+ private_key=eth_acct.key,
356
+ )
357
+ else:
358
+ # From provided key
359
+ if isinstance(private_key, str):
360
+ if not private_key.startswith("0x"):
361
+ private_key = "0x" + private_key
362
+ eth_acct = EthAccount.from_key(private_key)
363
+ account = Account(
364
+ address=eth_acct.address,
365
+ private_key=eth_acct.key,
366
+ )
367
+
368
+ return self._register(account)
369
+
370
+ def from_mnemonic(
371
+ self,
372
+ mnemonic: str,
373
+ count: int = 1,
374
+ offset: int = 0,
375
+ passphrase: str = "",
376
+ ) -> Account | list[Account]:
377
+ """Generate account(s) from BIP39 mnemonic.
378
+
379
+ Args:
380
+ mnemonic: BIP39 mnemonic phrase
381
+ count: Number of accounts to derive
382
+ offset: Starting index
383
+ passphrase: Optional BIP39 passphrase
384
+
385
+ Returns:
386
+ Single Account if count=1, else list of Accounts
387
+ """
388
+ results = []
389
+ for i in range(offset, offset + count):
390
+ # Standard Ethereum derivation path
391
+ path = f"m/44'/60'/0'/0/{i}"
392
+ eth_acct = EthAccount.from_mnemonic(
393
+ mnemonic,
394
+ passphrase=passphrase,
395
+ account_path=path,
396
+ )
397
+ account = Account(
398
+ address=eth_acct.address,
399
+ private_key=eth_acct.key,
400
+ )
401
+ results.append(self._register(account))
402
+
403
+ return results[0] if count == 1 else results
404
+
405
+ def at(self, address: str) -> Account:
406
+ """Get loaded account by address.
407
+
408
+ Args:
409
+ address: Hex address (0x...)
410
+
411
+ Returns:
412
+ Account instance
413
+
414
+ Raises:
415
+ KeyError: If address not loaded
416
+ """
417
+ key = address.lower()
418
+ if key not in self._by_address:
419
+ raise KeyError(f"Account {address} not loaded")
420
+ return self._by_address[key]
421
+
422
+ def remove(self, account: Account | str) -> None:
423
+ """Remove account from loaded list (does not delete keystore).
424
+
425
+ Args:
426
+ account: Account instance or address string
427
+ """
428
+ if isinstance(account, str):
429
+ account = self.at(account)
430
+
431
+ self._loaded.remove(account)
432
+ self._by_address.pop(account.address.lower(), None)
433
+ if account.alias:
434
+ self._by_alias.pop(account.alias.lower(), None)
435
+
436
+ def clear(self) -> None:
437
+ """Remove all loaded accounts."""
438
+ self._loaded.clear()
439
+ self._by_address.clear()
440
+ self._by_alias.clear()
441
+ self.default = None
442
+
443
+ def __getitem__(self, index: int) -> Account:
444
+ """Get account by index."""
445
+ return self._loaded[index]
446
+
447
+ def __len__(self) -> int:
448
+ return len(self._loaded)
449
+
450
+ def __iter__(self) -> Iterator[Account]:
451
+ return iter(self._loaded)
452
+
453
+ def __contains__(self, item: str | Account) -> bool:
454
+ if isinstance(item, Account):
455
+ return item in self._loaded
456
+ return item.lower() in self._by_address or item.lower() in self._by_alias
457
+
458
+ def __repr__(self) -> str:
459
+ available = len(self.list())
460
+ loaded = len(self._loaded)
461
+ return f"<Accounts [{loaded} loaded, {available} available]>"
462
+
463
+
464
+ def _init_accounts() -> None:
465
+ """Initialize global accounts singleton."""
466
+ global _accounts
467
+ _accounts = Accounts()
468
+
469
+
470
+ def _get_accounts() -> Accounts:
471
+ """Get accounts singleton, initializing if needed."""
472
+ global _accounts
473
+ if _accounts is None:
474
+ _init_accounts()
475
+ return _accounts
476
+
477
+
478
+ # Proxy for import-time access
479
+ class _AccountsProxy:
480
+ """Proxy that delegates to accounts singleton."""
481
+
482
+ def __getitem__(self, index: int) -> Account:
483
+ return _get_accounts()[index]
484
+
485
+ def __len__(self) -> int:
486
+ return len(_get_accounts())
487
+
488
+ def __iter__(self) -> Iterator[Account]:
489
+ return iter(_get_accounts())
490
+
491
+ def __contains__(self, item: str | Account) -> bool:
492
+ return item in _get_accounts()
493
+
494
+ def at(self, address: str) -> Account:
495
+ return _get_accounts().at(address)
496
+
497
+ def load(self, filename: str, password: str | None = None) -> Account:
498
+ return _get_accounts().load(filename, password)
499
+
500
+ def add(self, private_key: str | bytes | None = None) -> Account:
501
+ return _get_accounts().add(private_key)
502
+
503
+ def from_mnemonic(
504
+ self,
505
+ mnemonic: str,
506
+ count: int = 1,
507
+ offset: int = 0,
508
+ passphrase: str = "",
509
+ ) -> Account | list[Account]:
510
+ return _get_accounts().from_mnemonic(mnemonic, count, offset, passphrase)
511
+
512
+ def list(self) -> list[str]:
513
+ return _get_accounts().list()
514
+
515
+ def remove(self, account: Account | str) -> None:
516
+ return _get_accounts().remove(account)
517
+
518
+ def clear(self) -> None:
519
+ _get_accounts().clear()
520
+
521
+ @property
522
+ def default(self) -> Account | None:
523
+ return _get_accounts().default
524
+
525
+ @default.setter
526
+ def default(self, value: Account | None) -> None:
527
+ _get_accounts().default = value
528
+
529
+ def __repr__(self) -> str:
530
+ return repr(_get_accounts())
531
+
532
+
533
+ # Global proxy instance
534
+ accounts = _AccountsProxy()