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