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,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)