brawny 0.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brawny/__init__.py +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- brawny-0.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""Accounts management commands (brownie-compatible)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
# Default paths
|
|
15
|
+
DEFAULT_BRAWNY_ACCOUNTS_PATH = "~/.brawny/accounts"
|
|
16
|
+
DEFAULT_BROWNIE_ACCOUNTS_PATH = "~/.brownie/accounts"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_accounts_path() -> Path:
|
|
20
|
+
"""Get the brawny accounts path from env or default."""
|
|
21
|
+
path_str = os.environ.get("BRAWNY_ACCOUNTS_PATH", DEFAULT_BRAWNY_ACCOUNTS_PATH)
|
|
22
|
+
return Path(path_str).expanduser()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_brownie_accounts_path() -> Path:
|
|
26
|
+
"""Get the brownie accounts path."""
|
|
27
|
+
return Path(DEFAULT_BROWNIE_ACCOUNTS_PATH).expanduser()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_dir_permissions(path: Path) -> tuple[bool, str | None]:
|
|
31
|
+
"""Check if directory has secure permissions.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
(is_secure, error_message)
|
|
35
|
+
"""
|
|
36
|
+
if os.name != "posix":
|
|
37
|
+
return True, None
|
|
38
|
+
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return True, None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
mode = path.stat().st_mode
|
|
44
|
+
except OSError:
|
|
45
|
+
return True, None
|
|
46
|
+
|
|
47
|
+
# Check if group or world readable/writable
|
|
48
|
+
insecure = mode & (stat.S_IRWXG | stat.S_IRWXO)
|
|
49
|
+
if insecure:
|
|
50
|
+
return False, f"Directory {path} has insecure permissions ({oct(mode & 0o777)}). Use --fix-perms to fix."
|
|
51
|
+
|
|
52
|
+
return True, None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fix_permissions(path: Path) -> None:
|
|
56
|
+
"""Fix directory and file permissions to be secure."""
|
|
57
|
+
if os.name != "posix":
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if path.is_dir():
|
|
61
|
+
os.chmod(path, 0o700)
|
|
62
|
+
for child in path.iterdir():
|
|
63
|
+
fix_permissions(child)
|
|
64
|
+
elif path.is_file():
|
|
65
|
+
os.chmod(path, 0o600)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_address_from_keystore(file_path: Path) -> str | None:
|
|
69
|
+
"""Extract address from a keystore file."""
|
|
70
|
+
try:
|
|
71
|
+
with open(file_path) as f:
|
|
72
|
+
data = json.load(f)
|
|
73
|
+
addr = data.get("address", "")
|
|
74
|
+
if addr and not addr.startswith("0x"):
|
|
75
|
+
addr = f"0x{addr}"
|
|
76
|
+
return addr.lower() if addr else None
|
|
77
|
+
except (json.JSONDecodeError, OSError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_keystore_files(path: Path) -> list[tuple[str, Path, str | None]]:
|
|
82
|
+
"""List keystore files in a directory.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of (name, path, address) tuples
|
|
86
|
+
"""
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
results = []
|
|
91
|
+
for file_path in sorted(path.glob("*.json")):
|
|
92
|
+
name = file_path.stem
|
|
93
|
+
address = get_address_from_keystore(file_path)
|
|
94
|
+
results.append((name, file_path, address))
|
|
95
|
+
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@click.group()
|
|
100
|
+
def accounts() -> None:
|
|
101
|
+
"""Manage accounts (brownie-compatible keystore)."""
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@accounts.command("list")
|
|
106
|
+
@click.option(
|
|
107
|
+
"--include-brownie",
|
|
108
|
+
is_flag=True,
|
|
109
|
+
help="Also show accounts from ~/.brownie/accounts (read-only)",
|
|
110
|
+
)
|
|
111
|
+
@click.option(
|
|
112
|
+
"--path",
|
|
113
|
+
"accounts_path",
|
|
114
|
+
default=None,
|
|
115
|
+
help=f"Path to accounts directory (default: $BRAWNY_ACCOUNTS_PATH or {DEFAULT_BRAWNY_ACCOUNTS_PATH})",
|
|
116
|
+
)
|
|
117
|
+
def accounts_list(include_brownie: bool, accounts_path: str | None) -> None:
|
|
118
|
+
"""List available accounts."""
|
|
119
|
+
brawny_path = Path(accounts_path).expanduser() if accounts_path else get_accounts_path()
|
|
120
|
+
brownie_path = get_brownie_accounts_path()
|
|
121
|
+
|
|
122
|
+
brawny_accounts = list_keystore_files(brawny_path)
|
|
123
|
+
brownie_accounts = list_keystore_files(brownie_path) if include_brownie else []
|
|
124
|
+
|
|
125
|
+
# First-run prompt: if brawny is empty but brownie has accounts
|
|
126
|
+
if not brawny_accounts and not include_brownie and brownie_path.exists():
|
|
127
|
+
brownie_count = len(list_keystore_files(brownie_path))
|
|
128
|
+
if brownie_count > 0:
|
|
129
|
+
click.echo()
|
|
130
|
+
click.echo(click.style(f"Found {brownie_count} Brownie account(s) in {brownie_path}", dim=True))
|
|
131
|
+
if click.confirm("Import Brownie accounts into Brawny?", default=False):
|
|
132
|
+
# Re-invoke with import
|
|
133
|
+
ctx = click.get_current_context()
|
|
134
|
+
ctx.invoke(accounts_import, from_source="brownie", all_accounts=True)
|
|
135
|
+
# Refresh list
|
|
136
|
+
brawny_accounts = list_keystore_files(brawny_path)
|
|
137
|
+
else:
|
|
138
|
+
click.echo(click.style("Tip: Use --include-brownie to view without importing", dim=True))
|
|
139
|
+
click.echo()
|
|
140
|
+
|
|
141
|
+
has_any = False
|
|
142
|
+
|
|
143
|
+
# Show brawny accounts
|
|
144
|
+
if brawny_accounts:
|
|
145
|
+
has_any = True
|
|
146
|
+
click.echo()
|
|
147
|
+
click.echo(click.style(str(brawny_path), dim=True))
|
|
148
|
+
for name, _, address in brawny_accounts:
|
|
149
|
+
addr_display = address or "unknown"
|
|
150
|
+
click.echo(f" {click.style(name, fg='cyan')} {click.style(addr_display, dim=True)}")
|
|
151
|
+
|
|
152
|
+
# Show brownie accounts (read-only)
|
|
153
|
+
if brownie_accounts:
|
|
154
|
+
has_any = True
|
|
155
|
+
# Collect brawny addresses for duplicate detection
|
|
156
|
+
brawny_addresses = {addr for _, _, addr in brawny_accounts if addr}
|
|
157
|
+
|
|
158
|
+
click.echo()
|
|
159
|
+
click.echo(click.style(f"{brownie_path} (read-only)", dim=True))
|
|
160
|
+
for name, _, address in brownie_accounts:
|
|
161
|
+
addr_display = address or "unknown"
|
|
162
|
+
# Mark if already in brawny
|
|
163
|
+
suffix = ""
|
|
164
|
+
if address and address in brawny_addresses:
|
|
165
|
+
suffix = click.style(" (also in brawny)", dim=True)
|
|
166
|
+
click.echo(f" {click.style(name, fg='yellow')} {click.style(addr_display, dim=True)}{suffix}")
|
|
167
|
+
|
|
168
|
+
if has_any:
|
|
169
|
+
click.echo()
|
|
170
|
+
else:
|
|
171
|
+
click.echo()
|
|
172
|
+
click.echo(click.style("No accounts found.", dim=True))
|
|
173
|
+
click.echo(f" Import a key: brawny accounts import --private-key 0x...")
|
|
174
|
+
if brownie_path.exists():
|
|
175
|
+
click.echo(f" From Brownie: brawny accounts import --from brownie --all")
|
|
176
|
+
click.echo()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@accounts.command("import")
|
|
180
|
+
@click.option(
|
|
181
|
+
"--from",
|
|
182
|
+
"from_source",
|
|
183
|
+
type=click.Choice(["brownie"]),
|
|
184
|
+
default=None,
|
|
185
|
+
help="Import from external source (brownie)",
|
|
186
|
+
)
|
|
187
|
+
@click.option(
|
|
188
|
+
"--all",
|
|
189
|
+
"all_accounts",
|
|
190
|
+
is_flag=True,
|
|
191
|
+
help="Import all accounts from source",
|
|
192
|
+
)
|
|
193
|
+
@click.option(
|
|
194
|
+
"--link",
|
|
195
|
+
"link_mode",
|
|
196
|
+
is_flag=True,
|
|
197
|
+
help="Create symlinks instead of copies (unix only)",
|
|
198
|
+
)
|
|
199
|
+
@click.option(
|
|
200
|
+
"--copy",
|
|
201
|
+
"copy_mode",
|
|
202
|
+
is_flag=True,
|
|
203
|
+
help="Create physical copies (default on Windows)",
|
|
204
|
+
)
|
|
205
|
+
@click.option(
|
|
206
|
+
"--overwrite",
|
|
207
|
+
is_flag=True,
|
|
208
|
+
help="Overwrite existing files",
|
|
209
|
+
)
|
|
210
|
+
@click.option(
|
|
211
|
+
"--rename",
|
|
212
|
+
"auto_rename",
|
|
213
|
+
is_flag=True,
|
|
214
|
+
help="Auto-rename on conflict (e.g., name_1.json)",
|
|
215
|
+
)
|
|
216
|
+
@click.option(
|
|
217
|
+
"--fix-perms",
|
|
218
|
+
"fix_perms",
|
|
219
|
+
is_flag=True,
|
|
220
|
+
help="Fix directory permissions if insecure",
|
|
221
|
+
)
|
|
222
|
+
@click.option(
|
|
223
|
+
"--name",
|
|
224
|
+
"wallet_name",
|
|
225
|
+
default=None,
|
|
226
|
+
help="Wallet name (for --private-key import)",
|
|
227
|
+
)
|
|
228
|
+
@click.option(
|
|
229
|
+
"--private-key",
|
|
230
|
+
"private_key",
|
|
231
|
+
default=None,
|
|
232
|
+
help="Private key to import (hex)",
|
|
233
|
+
)
|
|
234
|
+
@click.option(
|
|
235
|
+
"--password",
|
|
236
|
+
"password_value",
|
|
237
|
+
default=None,
|
|
238
|
+
help="Keystore password (avoid in shell history)",
|
|
239
|
+
)
|
|
240
|
+
@click.option(
|
|
241
|
+
"--password-env",
|
|
242
|
+
"password_env",
|
|
243
|
+
default="BRAWNY_KEYSTORE_PASSWORD",
|
|
244
|
+
help="Environment variable for password",
|
|
245
|
+
)
|
|
246
|
+
@click.option(
|
|
247
|
+
"--path",
|
|
248
|
+
"out_path",
|
|
249
|
+
default=None,
|
|
250
|
+
help=f"Output directory (default: $BRAWNY_ACCOUNTS_PATH or {DEFAULT_BRAWNY_ACCOUNTS_PATH})",
|
|
251
|
+
)
|
|
252
|
+
@click.argument("names", nargs=-1)
|
|
253
|
+
def accounts_import(
|
|
254
|
+
from_source: str | None,
|
|
255
|
+
all_accounts: bool,
|
|
256
|
+
link_mode: bool,
|
|
257
|
+
copy_mode: bool,
|
|
258
|
+
overwrite: bool,
|
|
259
|
+
auto_rename: bool,
|
|
260
|
+
fix_perms: bool,
|
|
261
|
+
wallet_name: str | None,
|
|
262
|
+
private_key: str | None,
|
|
263
|
+
password_value: str | None,
|
|
264
|
+
password_env: str,
|
|
265
|
+
out_path: str | None,
|
|
266
|
+
names: tuple[str, ...],
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Import accounts from various sources.
|
|
269
|
+
|
|
270
|
+
Examples:
|
|
271
|
+
|
|
272
|
+
# Import a private key
|
|
273
|
+
brawny accounts import --private-key 0x... --name worker
|
|
274
|
+
|
|
275
|
+
# Import all Brownie accounts (symlinks)
|
|
276
|
+
brawny accounts import --from brownie --all --link
|
|
277
|
+
|
|
278
|
+
# Import specific Brownie accounts (copies)
|
|
279
|
+
brawny accounts import --from brownie hot_wallet personal_bot
|
|
280
|
+
|
|
281
|
+
# List available Brownie accounts
|
|
282
|
+
brawny accounts import --from brownie
|
|
283
|
+
"""
|
|
284
|
+
dest_path = Path(out_path).expanduser() if out_path else get_accounts_path()
|
|
285
|
+
|
|
286
|
+
# Check destination permissions
|
|
287
|
+
is_secure, perm_error = check_dir_permissions(dest_path)
|
|
288
|
+
if not is_secure:
|
|
289
|
+
if fix_perms:
|
|
290
|
+
click.echo(f"Fixing permissions on {dest_path}...")
|
|
291
|
+
fix_permissions(dest_path)
|
|
292
|
+
else:
|
|
293
|
+
raise click.ClickException(perm_error or "Insecure permissions")
|
|
294
|
+
|
|
295
|
+
# Branch: import from brownie
|
|
296
|
+
if from_source == "brownie":
|
|
297
|
+
_import_from_brownie(
|
|
298
|
+
dest_path=dest_path,
|
|
299
|
+
names=names,
|
|
300
|
+
all_accounts=all_accounts,
|
|
301
|
+
link_mode=link_mode,
|
|
302
|
+
copy_mode=copy_mode,
|
|
303
|
+
overwrite=overwrite,
|
|
304
|
+
auto_rename=auto_rename,
|
|
305
|
+
fix_perms=fix_perms,
|
|
306
|
+
)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
# Branch: import private key
|
|
310
|
+
if private_key:
|
|
311
|
+
_import_private_key(
|
|
312
|
+
dest_path=dest_path,
|
|
313
|
+
private_key=private_key,
|
|
314
|
+
wallet_name=wallet_name,
|
|
315
|
+
password_value=password_value,
|
|
316
|
+
password_env=password_env,
|
|
317
|
+
overwrite=overwrite,
|
|
318
|
+
)
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
# No valid import source
|
|
322
|
+
raise click.ClickException(
|
|
323
|
+
"Specify --private-key or --from brownie. "
|
|
324
|
+
"Use 'brawny accounts import --help' for usage."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _import_from_brownie(
|
|
329
|
+
dest_path: Path,
|
|
330
|
+
names: tuple[str, ...],
|
|
331
|
+
all_accounts: bool,
|
|
332
|
+
link_mode: bool,
|
|
333
|
+
copy_mode: bool,
|
|
334
|
+
overwrite: bool,
|
|
335
|
+
auto_rename: bool,
|
|
336
|
+
fix_perms: bool,
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Import accounts from Brownie."""
|
|
339
|
+
brownie_path = get_brownie_accounts_path()
|
|
340
|
+
|
|
341
|
+
if not brownie_path.exists():
|
|
342
|
+
raise click.ClickException(f"Brownie accounts directory not found: {brownie_path}")
|
|
343
|
+
|
|
344
|
+
available = list_keystore_files(brownie_path)
|
|
345
|
+
if not available:
|
|
346
|
+
raise click.ClickException(f"No accounts found in {brownie_path}")
|
|
347
|
+
|
|
348
|
+
# If no names and not --all, list available
|
|
349
|
+
if not names and not all_accounts:
|
|
350
|
+
click.echo()
|
|
351
|
+
click.echo(f"Available Brownie accounts ({brownie_path}):")
|
|
352
|
+
click.echo()
|
|
353
|
+
for name, _, address in available:
|
|
354
|
+
addr_display = address or "unknown"
|
|
355
|
+
click.echo(f" {click.style(name, fg='yellow')} {click.style(addr_display, dim=True)}")
|
|
356
|
+
click.echo()
|
|
357
|
+
click.echo("Import with:")
|
|
358
|
+
click.echo(f" brawny accounts import --from brownie --all # all accounts")
|
|
359
|
+
click.echo(f" brawny accounts import --from brownie NAME [NAME] # specific accounts")
|
|
360
|
+
click.echo()
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
# Determine which accounts to import
|
|
364
|
+
if all_accounts:
|
|
365
|
+
to_import = available
|
|
366
|
+
else:
|
|
367
|
+
name_set = set(names)
|
|
368
|
+
to_import = [(n, p, a) for n, p, a in available if n in name_set]
|
|
369
|
+
missing = name_set - {n for n, _, _ in to_import}
|
|
370
|
+
if missing:
|
|
371
|
+
raise click.ClickException(f"Accounts not found in Brownie: {', '.join(sorted(missing))}")
|
|
372
|
+
|
|
373
|
+
# Determine mode: link vs copy
|
|
374
|
+
use_symlinks = link_mode
|
|
375
|
+
if copy_mode:
|
|
376
|
+
use_symlinks = False
|
|
377
|
+
elif not link_mode and not copy_mode:
|
|
378
|
+
# Default: symlinks on unix, copy on windows
|
|
379
|
+
use_symlinks = os.name == "posix"
|
|
380
|
+
|
|
381
|
+
# Ensure destination exists with secure permissions
|
|
382
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
if os.name == "posix":
|
|
384
|
+
os.chmod(dest_path, 0o700)
|
|
385
|
+
|
|
386
|
+
# Check for existing accounts (by name and address)
|
|
387
|
+
existing = list_keystore_files(dest_path)
|
|
388
|
+
existing_names = {n for n, _, _ in existing}
|
|
389
|
+
existing_addresses = {a for _, _, a in existing if a}
|
|
390
|
+
|
|
391
|
+
imported = 0
|
|
392
|
+
skipped = 0
|
|
393
|
+
|
|
394
|
+
for name, src_path, address in to_import:
|
|
395
|
+
dest_file = dest_path / f"{name}.json"
|
|
396
|
+
|
|
397
|
+
# Check for collision by address
|
|
398
|
+
if address and address in existing_addresses and not overwrite:
|
|
399
|
+
existing_name = next((n for n, _, a in existing if a == address), None)
|
|
400
|
+
click.echo(click.style(f" skip: {name} (address already exists as '{existing_name}')", fg="yellow"))
|
|
401
|
+
skipped += 1
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
# Check for collision by name
|
|
405
|
+
if name in existing_names and not overwrite:
|
|
406
|
+
if auto_rename:
|
|
407
|
+
# Find unique name
|
|
408
|
+
counter = 1
|
|
409
|
+
while f"{name}_{counter}" in existing_names:
|
|
410
|
+
counter += 1
|
|
411
|
+
new_name = f"{name}_{counter}"
|
|
412
|
+
dest_file = dest_path / f"{new_name}.json"
|
|
413
|
+
existing_names.add(new_name)
|
|
414
|
+
click.echo(click.style(f" rename: {name} -> {new_name}", dim=True))
|
|
415
|
+
else:
|
|
416
|
+
click.echo(click.style(f" skip: {name} (already exists, use --overwrite or --rename)", fg="yellow"))
|
|
417
|
+
skipped += 1
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
# Remove existing if overwriting
|
|
421
|
+
if dest_file.exists() or dest_file.is_symlink():
|
|
422
|
+
dest_file.unlink()
|
|
423
|
+
|
|
424
|
+
# Create link or copy
|
|
425
|
+
if use_symlinks:
|
|
426
|
+
dest_file.symlink_to(src_path.resolve())
|
|
427
|
+
click.echo(click.style(f" link: {name}", fg="green"))
|
|
428
|
+
else:
|
|
429
|
+
shutil.copy2(src_path, dest_file)
|
|
430
|
+
# Secure the copy
|
|
431
|
+
if os.name == "posix":
|
|
432
|
+
os.chmod(dest_file, 0o600)
|
|
433
|
+
click.echo(click.style(f" copy: {name}", fg="green"))
|
|
434
|
+
|
|
435
|
+
existing_names.add(name)
|
|
436
|
+
if address:
|
|
437
|
+
existing_addresses.add(address)
|
|
438
|
+
imported += 1
|
|
439
|
+
|
|
440
|
+
click.echo()
|
|
441
|
+
if imported > 0:
|
|
442
|
+
mode_str = "linked" if use_symlinks else "copied"
|
|
443
|
+
click.echo(f"Imported {imported} account(s) ({mode_str}) to {dest_path}")
|
|
444
|
+
if skipped > 0:
|
|
445
|
+
click.echo(click.style(f"Skipped {skipped} account(s)", dim=True))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _import_private_key(
|
|
449
|
+
dest_path: Path,
|
|
450
|
+
private_key: str,
|
|
451
|
+
wallet_name: str | None,
|
|
452
|
+
password_value: str | None,
|
|
453
|
+
password_env: str,
|
|
454
|
+
overwrite: bool,
|
|
455
|
+
) -> None:
|
|
456
|
+
"""Import a private key into an encrypted keystore file."""
|
|
457
|
+
from eth_account import Account
|
|
458
|
+
from web3 import Web3
|
|
459
|
+
|
|
460
|
+
# Get password
|
|
461
|
+
password = password_value or os.environ.get(password_env)
|
|
462
|
+
if password is None:
|
|
463
|
+
password = click.prompt(
|
|
464
|
+
"Keystore password",
|
|
465
|
+
hide_input=True,
|
|
466
|
+
confirmation_prompt=True,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Parse private key
|
|
470
|
+
if not private_key.startswith("0x"):
|
|
471
|
+
private_key = f"0x{private_key}"
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
account = Account.from_key(private_key)
|
|
475
|
+
except Exception as e:
|
|
476
|
+
raise click.ClickException(f"Invalid private key: {e}")
|
|
477
|
+
|
|
478
|
+
address = Web3.to_checksum_address(account.address)
|
|
479
|
+
|
|
480
|
+
# Check for duplicate address
|
|
481
|
+
existing = list_keystore_files(dest_path)
|
|
482
|
+
for name, _, addr in existing:
|
|
483
|
+
if addr and addr.lower() == address.lower():
|
|
484
|
+
if not overwrite:
|
|
485
|
+
raise click.ClickException(
|
|
486
|
+
f"Address {address} already exists as '{name}'. Use --overwrite to replace."
|
|
487
|
+
)
|
|
488
|
+
# Remove existing
|
|
489
|
+
(dest_path / f"{name}.json").unlink()
|
|
490
|
+
break
|
|
491
|
+
|
|
492
|
+
# Encrypt
|
|
493
|
+
keystore_data = Account.encrypt(account.key, password)
|
|
494
|
+
|
|
495
|
+
# Ensure destination exists
|
|
496
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
497
|
+
if os.name == "posix":
|
|
498
|
+
os.chmod(dest_path, 0o700)
|
|
499
|
+
|
|
500
|
+
# Determine filename
|
|
501
|
+
if wallet_name:
|
|
502
|
+
filename = f"{wallet_name}.json"
|
|
503
|
+
else:
|
|
504
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S.%fZ")
|
|
505
|
+
filename = f"UTC--{timestamp}--{address[2:].lower()}.json"
|
|
506
|
+
|
|
507
|
+
file_path = dest_path / filename
|
|
508
|
+
if file_path.exists() and not overwrite:
|
|
509
|
+
raise click.ClickException(f"File already exists: {file_path}. Use --overwrite to replace.")
|
|
510
|
+
|
|
511
|
+
# Write with secure permissions
|
|
512
|
+
file_path.write_text(json.dumps(keystore_data, indent=2))
|
|
513
|
+
if os.name == "posix":
|
|
514
|
+
os.chmod(file_path, 0o600)
|
|
515
|
+
|
|
516
|
+
click.echo(f"Keystore created: {file_path}")
|
|
517
|
+
click.echo(f"Address: {address}")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@accounts.command("new")
|
|
521
|
+
@click.option(
|
|
522
|
+
"--name",
|
|
523
|
+
"wallet_name",
|
|
524
|
+
required=True,
|
|
525
|
+
help="Wallet name",
|
|
526
|
+
)
|
|
527
|
+
@click.option(
|
|
528
|
+
"--password",
|
|
529
|
+
"password_value",
|
|
530
|
+
default=None,
|
|
531
|
+
help="Keystore password (avoid in shell history)",
|
|
532
|
+
)
|
|
533
|
+
@click.option(
|
|
534
|
+
"--password-env",
|
|
535
|
+
"password_env",
|
|
536
|
+
default="BRAWNY_KEYSTORE_PASSWORD",
|
|
537
|
+
help="Environment variable for password",
|
|
538
|
+
)
|
|
539
|
+
@click.option(
|
|
540
|
+
"--path",
|
|
541
|
+
"out_path",
|
|
542
|
+
default=None,
|
|
543
|
+
help=f"Output directory (default: $BRAWNY_ACCOUNTS_PATH or {DEFAULT_BRAWNY_ACCOUNTS_PATH})",
|
|
544
|
+
)
|
|
545
|
+
def accounts_new(
|
|
546
|
+
wallet_name: str,
|
|
547
|
+
password_value: str | None,
|
|
548
|
+
password_env: str,
|
|
549
|
+
out_path: str | None,
|
|
550
|
+
) -> None:
|
|
551
|
+
"""Generate a new account."""
|
|
552
|
+
from eth_account import Account
|
|
553
|
+
from web3 import Web3
|
|
554
|
+
|
|
555
|
+
dest_path = Path(out_path).expanduser() if out_path else get_accounts_path()
|
|
556
|
+
|
|
557
|
+
# Get password
|
|
558
|
+
password = password_value or os.environ.get(password_env)
|
|
559
|
+
if password is None:
|
|
560
|
+
password = click.prompt(
|
|
561
|
+
"Keystore password",
|
|
562
|
+
hide_input=True,
|
|
563
|
+
confirmation_prompt=True,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# Generate new account
|
|
567
|
+
account = Account.create()
|
|
568
|
+
address = Web3.to_checksum_address(account.address)
|
|
569
|
+
|
|
570
|
+
# Encrypt
|
|
571
|
+
keystore_data = Account.encrypt(account.key, password)
|
|
572
|
+
|
|
573
|
+
# Ensure destination exists
|
|
574
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
575
|
+
if os.name == "posix":
|
|
576
|
+
os.chmod(dest_path, 0o700)
|
|
577
|
+
|
|
578
|
+
file_path = dest_path / f"{wallet_name}.json"
|
|
579
|
+
if file_path.exists():
|
|
580
|
+
raise click.ClickException(f"File already exists: {file_path}")
|
|
581
|
+
|
|
582
|
+
# Write with secure permissions
|
|
583
|
+
file_path.write_text(json.dumps(keystore_data, indent=2))
|
|
584
|
+
if os.name == "posix":
|
|
585
|
+
os.chmod(file_path, 0o600)
|
|
586
|
+
|
|
587
|
+
click.echo(f"New account created: {file_path}")
|
|
588
|
+
click.echo(f"Address: {address}")
|
|
589
|
+
click.echo()
|
|
590
|
+
click.echo(click.style("IMPORTANT: Back up your keystore file and remember your password!", fg="yellow"))
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@accounts.command("delete")
|
|
594
|
+
@click.argument("name")
|
|
595
|
+
@click.option(
|
|
596
|
+
"--path",
|
|
597
|
+
"accounts_path",
|
|
598
|
+
default=None,
|
|
599
|
+
help=f"Accounts directory (default: $BRAWNY_ACCOUNTS_PATH or {DEFAULT_BRAWNY_ACCOUNTS_PATH})",
|
|
600
|
+
)
|
|
601
|
+
@click.option(
|
|
602
|
+
"--force",
|
|
603
|
+
is_flag=True,
|
|
604
|
+
help="Skip confirmation prompt",
|
|
605
|
+
)
|
|
606
|
+
def accounts_delete(name: str, accounts_path: str | None, force: bool) -> None:
|
|
607
|
+
"""Delete an account."""
|
|
608
|
+
path = Path(accounts_path).expanduser() if accounts_path else get_accounts_path()
|
|
609
|
+
file_path = path / f"{name}.json"
|
|
610
|
+
|
|
611
|
+
if not file_path.exists() and not file_path.is_symlink():
|
|
612
|
+
raise click.ClickException(f"Account not found: {name}")
|
|
613
|
+
|
|
614
|
+
# Get address for confirmation
|
|
615
|
+
address = get_address_from_keystore(file_path) if file_path.exists() else None
|
|
616
|
+
|
|
617
|
+
if not force:
|
|
618
|
+
msg = f"Delete account '{name}'"
|
|
619
|
+
if address:
|
|
620
|
+
msg += f" ({address})"
|
|
621
|
+
msg += "?"
|
|
622
|
+
if not click.confirm(msg, default=False):
|
|
623
|
+
click.echo("Cancelled.")
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
file_path.unlink()
|
|
627
|
+
click.echo(f"Deleted: {name}")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def register(main) -> None:
|
|
631
|
+
"""Register accounts command with main CLI."""
|
|
632
|
+
main.add_command(accounts)
|