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
brawny/keystore.py ADDED
@@ -0,0 +1,484 @@
1
+ """Keystore implementations for transaction signing.
2
+
3
+ Provides secure key management with multiple backends:
4
+ - EnvKeystore: Load private keys from environment variables
5
+ - FileKeystore: Load from encrypted JSON keystore files
6
+
7
+ SECURITY: Private keys are NEVER logged, even at DEBUG level.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import stat
15
+ from abc import ABC, abstractmethod
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ from eth_account import Account
20
+ from eth_account.signers.local import LocalAccount
21
+ from dotenv import load_dotenv
22
+ from web3 import Web3
23
+ from web3.types import TxParams
24
+
25
+ from brawny.logging import get_logger
26
+ from brawny.model.errors import KeystoreError
27
+ from brawny.model.startup import StartupMessage
28
+
29
+ # Load .env so keystore passwords are available in environment.
30
+ load_dotenv()
31
+
32
+ logger = get_logger(__name__)
33
+
34
+ if TYPE_CHECKING:
35
+ from eth_account.datastructures import SignedTransaction
36
+
37
+
38
+ class Keystore(ABC):
39
+ """Abstract keystore interface for signing transactions.
40
+
41
+ Implementations must:
42
+ - Never log or expose private key material
43
+ - Be synchronous (no network calls during signing)
44
+ - Return consistent addresses for the same key_id
45
+ """
46
+
47
+ @abstractmethod
48
+ def get_address(self, key_id: str) -> str:
49
+ """Return the checksum address for a key identifier.
50
+
51
+ Args:
52
+ key_id: Key identifier (address or alias)
53
+
54
+ Returns:
55
+ Checksum address
56
+
57
+ Raises:
58
+ KeystoreError: If key not found
59
+ """
60
+ ...
61
+
62
+ @abstractmethod
63
+ def sign_transaction(self, tx_dict: TxParams, key_id: str) -> SignedTransaction:
64
+ """Sign a transaction.
65
+
66
+ Must be synchronous and not make network calls.
67
+ Must never log private key material.
68
+
69
+ Args:
70
+ tx_dict: Transaction parameters
71
+ key_id: Key identifier (address or alias)
72
+
73
+ Returns:
74
+ Signed transaction
75
+
76
+ Raises:
77
+ KeystoreError: If signing fails
78
+ """
79
+ ...
80
+
81
+ @abstractmethod
82
+ def list_keys(self) -> list[str]:
83
+ """List available key identifiers.
84
+
85
+ Returns:
86
+ List of key identifiers (addresses)
87
+ """
88
+ ...
89
+
90
+ def has_key(self, key_id: str) -> bool:
91
+ """Check if a key exists.
92
+
93
+ Args:
94
+ key_id: Key identifier to check
95
+
96
+ Returns:
97
+ True if key exists
98
+ """
99
+ try:
100
+ self.get_address(key_id)
101
+ return True
102
+ except KeystoreError:
103
+ return False
104
+
105
+ def get_warnings(self) -> list[StartupMessage]:
106
+ """Get startup warnings collected during initialization.
107
+
108
+ Returns:
109
+ List of startup warning messages
110
+ """
111
+ return []
112
+
113
+
114
+ class EnvKeystore(Keystore):
115
+ """Load private keys from environment variables.
116
+
117
+ Supports two formats:
118
+ - BRAWNY_SIGNER_PRIVATE_KEY: Single signer
119
+ - BRAWNY_SIGNER_{ADDRESS}_PRIVATE_KEY: Multiple signers by address
120
+
121
+ Example:
122
+ BRAWNY_SIGNER_PRIVATE_KEY=0x...
123
+ BRAWNY_SIGNER_0x1234567890ABCDEF_PRIVATE_KEY=0x...
124
+ """
125
+
126
+ def __init__(self, allowed_signers: list[str] | None = None) -> None:
127
+ """Initialize the keystore.
128
+
129
+ Args:
130
+ allowed_signers: Optional list of allowed signer addresses.
131
+ If None, all found signers are allowed.
132
+ """
133
+ self._accounts: dict[str, LocalAccount] = {}
134
+ self._allowed_signers = (
135
+ {Web3.to_checksum_address(s) for s in allowed_signers}
136
+ if allowed_signers
137
+ else None
138
+ )
139
+ self._load_keys()
140
+
141
+ def _load_keys(self) -> None:
142
+ """Load keys from environment variables."""
143
+ # Check for single signer key
144
+ single_key = os.environ.get("BRAWNY_SIGNER_PRIVATE_KEY")
145
+ if single_key:
146
+ self._add_key(single_key)
147
+
148
+ # Check for address-specific keys
149
+ for key, value in os.environ.items():
150
+ if key.startswith("BRAWNY_SIGNER_") and key.endswith("_PRIVATE_KEY"):
151
+ # Skip the generic key
152
+ if key == "BRAWNY_SIGNER_PRIVATE_KEY":
153
+ continue
154
+ self._add_key(value)
155
+
156
+ def _add_key(self, private_key: str) -> None:
157
+ """Add a key to the keystore."""
158
+ try:
159
+ # Normalize private key format
160
+ if not private_key.startswith("0x"):
161
+ private_key = f"0x{private_key}"
162
+
163
+ account = Account.from_key(private_key)
164
+ address = Web3.to_checksum_address(account.address)
165
+
166
+ # Check against allowed signers
167
+ if self._allowed_signers and address not in self._allowed_signers:
168
+ return
169
+
170
+ self._accounts[address] = account
171
+ except Exception as e:
172
+ # Don't expose the key in error messages
173
+ raise KeystoreError(f"Failed to load private key: {type(e).__name__}")
174
+
175
+ def get_address(self, key_id: str) -> str:
176
+ """Get the checksum address for a key."""
177
+ try:
178
+ address = Web3.to_checksum_address(key_id)
179
+ except Exception:
180
+ raise KeystoreError(f"Invalid key identifier: {key_id}")
181
+
182
+ if address not in self._accounts:
183
+ raise KeystoreError(f"Key not found: {address}")
184
+
185
+ return address
186
+
187
+ def sign_transaction(self, tx_dict: TxParams, key_id: str) -> SignedTransaction:
188
+ """Sign a transaction with the specified key."""
189
+ address = self.get_address(key_id)
190
+ account = self._accounts[address]
191
+
192
+ try:
193
+ return account.sign_transaction(tx_dict)
194
+ except Exception as e:
195
+ # Don't expose key material in error
196
+ raise KeystoreError(f"Signing failed: {type(e).__name__}: {e}")
197
+
198
+ def list_keys(self) -> list[str]:
199
+ """List all available signer addresses."""
200
+ return list(self._accounts.keys())
201
+
202
+
203
+ class FileKeystore(Keystore):
204
+ """Load keys from encrypted JSON keystore files.
205
+
206
+ Expects keystore files in the format produced by geth/web3:
207
+ {
208
+ "address": "...",
209
+ "crypto": {...},
210
+ "id": "...",
211
+ "version": 3
212
+ }
213
+
214
+ Password resolution order:
215
+ 1. BRAWNY_KEYSTORE_PASSWORD_{NAME} (per-wallet)
216
+ 2. {name}.password file next to keystore
217
+ 3. BRAWNY_KEYSTORE_PASSWORD (global)
218
+ 4. BROWNIE_PASSWORD (only if brownie_password_fallback=True)
219
+ """
220
+
221
+ def __init__(
222
+ self,
223
+ keystore_path: str | Path,
224
+ allowed_signers: list[str] | None = None,
225
+ include_brownie: bool = False,
226
+ brownie_password_fallback: bool = False,
227
+ ) -> None:
228
+ """Initialize the keystore.
229
+
230
+ Args:
231
+ keystore_path: Path to keystore directory or single file
232
+ allowed_signers: Optional list of allowed signer addresses
233
+ include_brownie: Also load accounts from ~/.brownie/accounts (read-only)
234
+ brownie_password_fallback: Use BROWNIE_PASSWORD as password fallback
235
+ """
236
+ self._accounts: dict[str, LocalAccount] = {}
237
+ self._name_to_address: dict[str, str] = {}
238
+ self._warnings: list[StartupMessage] = []
239
+ self._allowed_signers = (
240
+ {Web3.to_checksum_address(s) for s in allowed_signers}
241
+ if allowed_signers
242
+ else None
243
+ )
244
+ self._keystore_path = Path(keystore_path).expanduser()
245
+ self._include_brownie = include_brownie
246
+ self._brownie_password_fallback = brownie_password_fallback
247
+ self._brownie_path = Path("~/.brownie/accounts").expanduser()
248
+ self._load_keys()
249
+
250
+ def _load_keys(self) -> None:
251
+ """Load keys from keystore files."""
252
+ if not self._keystore_path.exists():
253
+ # Create the directory if it doesn't exist (graceful handling)
254
+ if self._keystore_path.suffix == "": # It's a directory path
255
+ self._keystore_path.mkdir(parents=True, exist_ok=True)
256
+ if os.name == "posix":
257
+ os.chmod(self._keystore_path, 0o700)
258
+ # If it's a file path that doesn't exist, that's an error
259
+ elif not self._include_brownie:
260
+ raise KeystoreError(f"Keystore path does not exist: {self._keystore_path}")
261
+
262
+ if self._keystore_path.exists():
263
+ self._load_from_path(self._keystore_path)
264
+
265
+ # Also load from brownie if requested
266
+ if self._include_brownie and self._brownie_path.exists():
267
+ self._load_from_path(self._brownie_path, is_brownie=True)
268
+
269
+ # Warn if no accounts loaded
270
+ if not self._accounts:
271
+ logger.warning(
272
+ "keystore.empty",
273
+ path=str(self._keystore_path),
274
+ )
275
+
276
+ def _load_from_path(self, path: Path, is_brownie: bool = False) -> None:
277
+ """Load keys from a path (file or directory)."""
278
+ if path.is_file():
279
+ self._warn_insecure_path(path)
280
+ name = path.stem
281
+ self._load_keystore_file(path, wallet_name=name, is_brownie=is_brownie)
282
+ else:
283
+ self._warn_insecure_path(path)
284
+ # Load all .json files in directory
285
+ for file_path in path.glob("*.json"):
286
+ try:
287
+ self._warn_insecure_path(file_path)
288
+ wallet_name = file_path.stem
289
+ self._load_keystore_file(file_path, wallet_name=wallet_name, is_brownie=is_brownie)
290
+ except KeystoreError as e:
291
+ # Log the failure so users know WHY a keystore wasn't loaded
292
+ logger.warning(
293
+ "keystore.load_failed",
294
+ file=str(file_path),
295
+ wallet_name=wallet_name,
296
+ error=str(e),
297
+ )
298
+
299
+ def _warn_insecure_path(self, path: Path) -> None:
300
+ """Collect warning if keystore path permissions are too open."""
301
+ if os.name != "posix":
302
+ return
303
+ try:
304
+ mode = path.stat().st_mode
305
+ except OSError:
306
+ return
307
+
308
+ insecure = mode & (stat.S_IRWXG | stat.S_IRWXO)
309
+ if insecure:
310
+ mode_str = oct(mode & 0o777)
311
+ self._warnings.append(
312
+ StartupMessage(
313
+ level="warning",
314
+ code="keystore.insecure_permissions",
315
+ message=f"Insecure permissions: {path.name} ({mode_str})",
316
+ fix=f"chmod 600 {path}",
317
+ )
318
+ )
319
+
320
+ def _load_keystore_file(
321
+ self, file_path: Path, wallet_name: str | None, is_brownie: bool = False
322
+ ) -> None:
323
+ """Load a single keystore file."""
324
+ try:
325
+ with open(file_path) as f:
326
+ keystore_json = json.load(f)
327
+
328
+ # Get password (brownie fallback only allowed for brownie sources or if explicitly enabled)
329
+ use_brownie_fallback = is_brownie or self._brownie_password_fallback
330
+ password = self._get_password(file_path, wallet_name, use_brownie_fallback)
331
+
332
+ # Decrypt and load account
333
+ private_key = Account.decrypt(keystore_json, password)
334
+ account = Account.from_key(private_key)
335
+ address = Web3.to_checksum_address(account.address)
336
+
337
+ # Check against allowed signers
338
+ if self._allowed_signers and address not in self._allowed_signers:
339
+ return
340
+
341
+ self._accounts[address] = account
342
+ if wallet_name:
343
+ if wallet_name in self._name_to_address:
344
+ existing = self._name_to_address[wallet_name]
345
+ if existing != address:
346
+ raise KeystoreError(
347
+ f"Duplicate wallet name '{wallet_name}' for different address"
348
+ )
349
+ self._name_to_address[wallet_name] = address
350
+ except KeystoreError:
351
+ raise # Re-raise with original message (e.g., "No password found...")
352
+ except json.JSONDecodeError:
353
+ raise KeystoreError(f"Invalid JSON in keystore file: {file_path}")
354
+ except Exception as e:
355
+ raise KeystoreError(f"Failed to load keystore: {type(e).__name__}")
356
+
357
+ def _get_password(
358
+ self,
359
+ keystore_path: Path,
360
+ wallet_name: str | None,
361
+ use_brownie_fallback: bool = False,
362
+ ) -> str:
363
+ """Get password for a keystore file.
364
+
365
+ Resolution order:
366
+ 1. BRAWNY_KEYSTORE_PASSWORD_{NAME} (per-wallet)
367
+ 2. {name}.password file next to keystore
368
+ 3. BRAWNY_KEYSTORE_PASSWORD (global)
369
+ 4. BROWNIE_PASSWORD (only if use_brownie_fallback=True)
370
+ """
371
+ if wallet_name:
372
+ env_name = f"BRAWNY_KEYSTORE_PASSWORD_{wallet_name.upper()}"
373
+ password = os.environ.get(env_name)
374
+ if password:
375
+ return password
376
+
377
+ name_password_file = keystore_path.parent / f"{wallet_name}.password"
378
+ if name_password_file.exists():
379
+ return name_password_file.read_text().strip()
380
+
381
+ # Check environment variable first
382
+ password = os.environ.get("BRAWNY_KEYSTORE_PASSWORD")
383
+ if password:
384
+ return password
385
+
386
+ # Check for .password file next to keystore
387
+ password_file = keystore_path.with_suffix(".password")
388
+ if password_file.exists():
389
+ return password_file.read_text().strip()
390
+
391
+ # Check for password file with same name
392
+ password_file = keystore_path.parent / f"{keystore_path.stem}.password"
393
+ if password_file.exists():
394
+ return password_file.read_text().strip()
395
+
396
+ # Brownie password fallback (only when explicitly allowed)
397
+ if use_brownie_fallback:
398
+ password = os.environ.get("BROWNIE_PASSWORD")
399
+ if password:
400
+ return password
401
+
402
+ raise KeystoreError(
403
+ f"No password found for {keystore_path}. "
404
+ "Set BRAWNY_KEYSTORE_PASSWORD or create a .password file."
405
+ )
406
+
407
+ def get_address(self, key_id: str) -> str:
408
+ """Get the checksum address for a key."""
409
+ if key_id in self._name_to_address:
410
+ return self._name_to_address[key_id]
411
+
412
+ try:
413
+ address = Web3.to_checksum_address(key_id)
414
+ except Exception:
415
+ raise KeystoreError(f"Invalid key identifier: {key_id}")
416
+
417
+ if address not in self._accounts:
418
+ raise KeystoreError(f"Key not found: {address}")
419
+
420
+ return address
421
+
422
+ def sign_transaction(self, tx_dict: TxParams, key_id: str) -> SignedTransaction:
423
+ """Sign a transaction with the specified key."""
424
+ address = self.get_address(key_id)
425
+ account = self._accounts[address]
426
+
427
+ try:
428
+ return account.sign_transaction(tx_dict)
429
+ except Exception as e:
430
+ raise KeystoreError(f"Signing failed: {type(e).__name__}: {e}")
431
+
432
+ def list_keys(self) -> list[str]:
433
+ """List all available signer addresses."""
434
+ if self._name_to_address:
435
+ return sorted(self._name_to_address.keys())
436
+ return list(self._accounts.keys())
437
+
438
+ def list_named_keys(self) -> dict[str, str]:
439
+ """Return mapping of wallet name to address."""
440
+ return dict(self._name_to_address)
441
+
442
+ def get_warnings(self) -> list[StartupMessage]:
443
+ """Get startup warnings collected during initialization."""
444
+ return self._warnings
445
+
446
+
447
+ def create_keystore(
448
+ keystore_type: str,
449
+ keystore_path: str | None = None,
450
+ allowed_signers: list[str] | None = None,
451
+ include_brownie: bool = False,
452
+ brownie_password_fallback: bool = False,
453
+ ) -> Keystore:
454
+ """Factory function to create a keystore instance.
455
+
456
+ Args:
457
+ keystore_type: Type of keystore ('env', 'file')
458
+ keystore_path: Path for file keystore
459
+ allowed_signers: Optional list of allowed signer addresses
460
+ include_brownie: Also load accounts from ~/.brownie/accounts (file keystore only)
461
+ brownie_password_fallback: Use BROWNIE_PASSWORD as password fallback
462
+
463
+ Returns:
464
+ Keystore instance
465
+
466
+ Raises:
467
+ KeystoreError: If keystore creation fails
468
+ """
469
+ if keystore_type == "env":
470
+ return EnvKeystore(allowed_signers=allowed_signers)
471
+ elif keystore_type == "file":
472
+ if not keystore_path:
473
+ raise KeystoreError("keystore_path is required for file keystore")
474
+ return FileKeystore(
475
+ keystore_path,
476
+ allowed_signers=allowed_signers,
477
+ include_brownie=include_brownie,
478
+ brownie_password_fallback=brownie_password_fallback,
479
+ )
480
+ else:
481
+ raise KeystoreError(
482
+ f"Unknown keystore type: {keystore_type}. "
483
+ "Must be one of: env, file"
484
+ )