brawny 0.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brawny/__init__.py +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- brawny-0.1.13.dist-info/top_level.txt +1 -0
brawny/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
|
+
)
|