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,495 @@
|
|
|
1
|
+
"""Interactive console for contract exploration (brownie-style).
|
|
2
|
+
|
|
3
|
+
Mirrors brownie console ergonomics:
|
|
4
|
+
- Contract("0x...") - Get contract handle
|
|
5
|
+
- chain.height - Current block number
|
|
6
|
+
- chain[-1] - Most recent block
|
|
7
|
+
- Wei("1 ether") - Unit conversion
|
|
8
|
+
|
|
9
|
+
Uses prompt_toolkit for dropdown completion and syntax highlighting.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import atexit
|
|
15
|
+
import code
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import traceback
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
from prompt_toolkit import PromptSession
|
|
26
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
27
|
+
from prompt_toolkit.history import FileHistory
|
|
28
|
+
from prompt_toolkit.lexers import PygmentsLexer
|
|
29
|
+
from prompt_toolkit.styles.pygments import style_from_pygments_cls
|
|
30
|
+
from pygments import highlight
|
|
31
|
+
from pygments.formatters import Terminal256Formatter
|
|
32
|
+
from pygments.lexers.python import PythonLexer
|
|
33
|
+
from pygments.styles import get_style_by_name
|
|
34
|
+
|
|
35
|
+
# ANSI color codes (Brownie-style)
|
|
36
|
+
_BASE = "\x1b[0;"
|
|
37
|
+
_COLORS = {
|
|
38
|
+
"red": "31", "green": "32", "yellow": "33", "blue": "34",
|
|
39
|
+
"magenta": "35", "cyan": "36", "white": "37",
|
|
40
|
+
}
|
|
41
|
+
_RESET = f"{_BASE}m"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _color(color_str: str) -> str:
|
|
45
|
+
"""Return ANSI escape code for color."""
|
|
46
|
+
if not color_str:
|
|
47
|
+
return _RESET
|
|
48
|
+
parts = color_str.split()
|
|
49
|
+
if len(parts) == 2 and parts[0] == "bright":
|
|
50
|
+
return f"{_BASE}1;{_COLORS.get(parts[1], '37')}m"
|
|
51
|
+
elif len(parts) == 2 and parts[0] == "dark":
|
|
52
|
+
return f"{_BASE}2;{_COLORS.get(parts[1], '37')}m"
|
|
53
|
+
return f"{_BASE}{_COLORS.get(color_str, '37')}m"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _format_tb(exc: Exception, start: int | None = None) -> str:
|
|
57
|
+
"""Format exception with colorized traceback (Brownie-style).
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
exc: The exception to format
|
|
61
|
+
start: Starting frame index (skip internal frames)
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(exc, SyntaxError) and exc.text is not None:
|
|
64
|
+
return _format_syntaxerror(exc)
|
|
65
|
+
|
|
66
|
+
base_path = str(Path(".").absolute())
|
|
67
|
+
tb_lines = traceback.format_tb(exc.__traceback__)
|
|
68
|
+
tb_lines = [line.replace("./", "") for line in tb_lines]
|
|
69
|
+
|
|
70
|
+
# Skip internal frames if start is specified
|
|
71
|
+
if start is not None:
|
|
72
|
+
tb_lines = tb_lines[start:]
|
|
73
|
+
|
|
74
|
+
formatted = []
|
|
75
|
+
for line in tb_lines:
|
|
76
|
+
parts = line.split("\n")
|
|
77
|
+
if len(parts) >= 1:
|
|
78
|
+
info = parts[0].replace(base_path, ".")
|
|
79
|
+
code_line = parts[1].strip() if len(parts) > 1 else ""
|
|
80
|
+
|
|
81
|
+
# Parse: ' File "path", line N, in func'
|
|
82
|
+
info = info.strip()
|
|
83
|
+
if info.startswith("File"):
|
|
84
|
+
# Extract components
|
|
85
|
+
try:
|
|
86
|
+
# File "path", line N, in func
|
|
87
|
+
file_part = info.split('"')[1] if '"' in info else "?"
|
|
88
|
+
line_match = re.search(r'line (\d+)', info)
|
|
89
|
+
line_num = line_match.group(1) if line_match else "?"
|
|
90
|
+
func_match = re.search(r'in (\w+)', info)
|
|
91
|
+
func_name = func_match.group(1) if func_match else "?"
|
|
92
|
+
|
|
93
|
+
# Shorten site-packages paths
|
|
94
|
+
if "site-packages/" in file_part:
|
|
95
|
+
file_part = file_part.split("site-packages/")[1]
|
|
96
|
+
|
|
97
|
+
formatted_line = (
|
|
98
|
+
f" {_color('dark white')}File {_color('bright magenta')}\"{file_part}\""
|
|
99
|
+
f"{_color('dark white')}, line {_color('bright blue')}{line_num}"
|
|
100
|
+
f"{_color('dark white')}, in {_color('bright cyan')}{func_name}{_RESET}"
|
|
101
|
+
)
|
|
102
|
+
if code_line:
|
|
103
|
+
formatted_line += f"\n {code_line}"
|
|
104
|
+
formatted.append(formatted_line)
|
|
105
|
+
except Exception:
|
|
106
|
+
formatted.append(line.rstrip())
|
|
107
|
+
else:
|
|
108
|
+
formatted.append(line.rstrip())
|
|
109
|
+
|
|
110
|
+
# Add exception line
|
|
111
|
+
exc_name = type(exc).__name__
|
|
112
|
+
exc_msg = str(exc)
|
|
113
|
+
formatted.append(f"{_color('bright red')}{exc_name}{_RESET}: {exc_msg}")
|
|
114
|
+
|
|
115
|
+
return "\n".join(formatted)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _format_syntaxerror(exc: SyntaxError) -> str:
|
|
119
|
+
"""Format SyntaxError with colorized output."""
|
|
120
|
+
base_path = str(Path(".").absolute())
|
|
121
|
+
filename = (exc.filename or "<console>").replace(base_path, ".")
|
|
122
|
+
lineno = exc.lineno or 1
|
|
123
|
+
text = exc.text or ""
|
|
124
|
+
offset = exc.offset or 0
|
|
125
|
+
|
|
126
|
+
# Calculate caret position
|
|
127
|
+
if text:
|
|
128
|
+
stripped = text.lstrip()
|
|
129
|
+
offset = offset + len(stripped) - len(text) + 3
|
|
130
|
+
|
|
131
|
+
result = (
|
|
132
|
+
f" {_color('dark white')}File {_color('bright magenta')}\"{filename}\""
|
|
133
|
+
f"{_color('dark white')}, line {_color('bright blue')}{lineno}{_RESET}\n"
|
|
134
|
+
)
|
|
135
|
+
if text:
|
|
136
|
+
result += f" {text.strip()}\n"
|
|
137
|
+
result += f"{' ' * offset}^\n"
|
|
138
|
+
result += f"{_color('bright red')}SyntaxError{_RESET}: {exc.msg}"
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ConsoleCompleter(Completer):
|
|
143
|
+
"""Dropdown tab-completion for the console (mirrors Brownie)."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, console: "BrawnyConsole"):
|
|
146
|
+
self.console = console
|
|
147
|
+
|
|
148
|
+
def get_completions(self, document, complete_event):
|
|
149
|
+
text = document.text_before_cursor
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
# Find the expression being completed (handles foo.bar, func(arg.attr, etc.)
|
|
153
|
+
match = re.search(r"([a-zA-Z_][\w\.]*)?$", text)
|
|
154
|
+
if not match:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
expr = match.group(1) or ""
|
|
158
|
+
|
|
159
|
+
if "." in expr:
|
|
160
|
+
# Attribute completion
|
|
161
|
+
base, partial = expr.rsplit(".", 1)
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
obj = eval(base, self.console.locals) # noqa: S307
|
|
165
|
+
except Exception:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
for attr in dir(obj):
|
|
169
|
+
if attr.startswith(partial):
|
|
170
|
+
# Skip private unless explicitly typing _
|
|
171
|
+
if attr.startswith("_") and not partial.startswith("_"):
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# Add ( for callables
|
|
175
|
+
try:
|
|
176
|
+
val = getattr(obj, attr)
|
|
177
|
+
suffix = "(" if callable(val) else ""
|
|
178
|
+
except Exception:
|
|
179
|
+
suffix = ""
|
|
180
|
+
|
|
181
|
+
yield Completion(attr + suffix, start_position=-len(partial))
|
|
182
|
+
else:
|
|
183
|
+
# Namespace completion
|
|
184
|
+
for name in self.console.locals:
|
|
185
|
+
if name.startswith(expr):
|
|
186
|
+
if name.startswith("_") and not expr.startswith("_"):
|
|
187
|
+
continue
|
|
188
|
+
yield Completion(name, start_position=-len(expr))
|
|
189
|
+
except Exception:
|
|
190
|
+
pass # Fail silently - no completions
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class BrawnyConsole(code.InteractiveConsole):
|
|
194
|
+
"""Brownie-style interactive console with colorized tracebacks."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, locals_dict: dict, history_file: str):
|
|
197
|
+
super().__init__(locals_dict)
|
|
198
|
+
|
|
199
|
+
# Setup prompt_toolkit session
|
|
200
|
+
style = style_from_pygments_cls(get_style_by_name("monokai"))
|
|
201
|
+
self._formatter = Terminal256Formatter(style="monokai")
|
|
202
|
+
|
|
203
|
+
self.prompt_session = PromptSession(
|
|
204
|
+
completer=ConsoleCompleter(self),
|
|
205
|
+
lexer=PygmentsLexer(PythonLexer),
|
|
206
|
+
history=FileHistory(history_file),
|
|
207
|
+
enable_history_search=True,
|
|
208
|
+
style=style,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def raw_input(self, prompt: str = "") -> str:
|
|
212
|
+
"""Use prompt_toolkit for input with completion."""
|
|
213
|
+
return self.prompt_session.prompt(prompt)
|
|
214
|
+
|
|
215
|
+
def showsyntaxerror(self, filename: str | None = None) -> None:
|
|
216
|
+
"""Display syntax error with colorized output."""
|
|
217
|
+
exc_info = sys.exc_info()
|
|
218
|
+
if exc_info[1] is not None:
|
|
219
|
+
tb = _format_tb(exc_info[1])
|
|
220
|
+
self.write(tb + "\n")
|
|
221
|
+
else:
|
|
222
|
+
super().showsyntaxerror(filename)
|
|
223
|
+
|
|
224
|
+
def showtraceback(self) -> None:
|
|
225
|
+
"""Display traceback with colorized output (skip internal frames)."""
|
|
226
|
+
exc_info = sys.exc_info()
|
|
227
|
+
if exc_info[1] is not None:
|
|
228
|
+
tb = _format_tb(exc_info[1], start=1)
|
|
229
|
+
self.write(tb + "\n")
|
|
230
|
+
else:
|
|
231
|
+
super().showtraceback()
|
|
232
|
+
|
|
233
|
+
def runsource(self, source: str, filename: str = "<input>", symbol: str = "single") -> bool:
|
|
234
|
+
"""Execute source with expression result highlighting."""
|
|
235
|
+
try:
|
|
236
|
+
code_obj = self.compile(source, filename, symbol)
|
|
237
|
+
except (OverflowError, SyntaxError, ValueError):
|
|
238
|
+
self.showsyntaxerror(filename)
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
if code_obj is None:
|
|
242
|
+
# Incomplete input - need more lines
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
# Try to capture return value for expression highlighting
|
|
246
|
+
try:
|
|
247
|
+
self.compile(source, filename, "eval")
|
|
248
|
+
# It's an expression - wrap it to capture result
|
|
249
|
+
wrapped_code = self.compile(f"__ret_value__ = {source}", filename, "exec")
|
|
250
|
+
self.runcode(wrapped_code)
|
|
251
|
+
if "__ret_value__" in self.locals and self.locals["__ret_value__"] is not None:
|
|
252
|
+
result = self.locals.pop("__ret_value__")
|
|
253
|
+
result_str = repr(result)
|
|
254
|
+
highlighted = highlight(result_str, PythonLexer(), self._formatter)
|
|
255
|
+
self.write(highlighted)
|
|
256
|
+
return False
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
# Not an expression - run as statement
|
|
261
|
+
self.runcode(code_obj)
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@click.command("console")
|
|
266
|
+
@click.option("--config", "config_path", default="./config.yaml", help="Path to config.yaml")
|
|
267
|
+
@click.option("--debug", is_flag=True, help="Enable debug logging")
|
|
268
|
+
def console(config_path: str, debug: bool) -> None:
|
|
269
|
+
"""Interactive Python console with contract helpers.
|
|
270
|
+
|
|
271
|
+
Brownie-style interface for contract exploration.
|
|
272
|
+
|
|
273
|
+
Examples:
|
|
274
|
+
|
|
275
|
+
brawny console
|
|
276
|
+
|
|
277
|
+
brawny console --debug
|
|
278
|
+
"""
|
|
279
|
+
import logging
|
|
280
|
+
import structlog
|
|
281
|
+
|
|
282
|
+
# Suppress all logs during startup for clean console UX
|
|
283
|
+
# Must configure both stdlib logging AND structlog to silence output
|
|
284
|
+
if not debug:
|
|
285
|
+
# Set root logger to CRITICAL to filter everything
|
|
286
|
+
logging.basicConfig(level=logging.CRITICAL, force=True)
|
|
287
|
+
# Configure structlog with filter_by_level so it respects logging levels
|
|
288
|
+
structlog.configure(
|
|
289
|
+
processors=[
|
|
290
|
+
structlog.stdlib.filter_by_level, # This respects logging.CRITICAL
|
|
291
|
+
structlog.processors.JSONRenderer(),
|
|
292
|
+
],
|
|
293
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
294
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
295
|
+
cache_logger_on_first_use=False, # Allow reconfiguration later
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
from brawny.logging import setup_logging, LogFormat
|
|
299
|
+
setup_logging(log_level="DEBUG", log_format=LogFormat.TEXT)
|
|
300
|
+
|
|
301
|
+
from brawny.alerts.contracts import ContractSystem
|
|
302
|
+
from brawny.config import Config
|
|
303
|
+
from brawny._rpc import RPCManager
|
|
304
|
+
|
|
305
|
+
if not os.path.exists(config_path):
|
|
306
|
+
click.echo(f"Config file not found: {config_path}", err=True)
|
|
307
|
+
sys.exit(1)
|
|
308
|
+
|
|
309
|
+
config = Config.from_yaml(config_path)
|
|
310
|
+
config, _ = config.apply_env_overrides()
|
|
311
|
+
|
|
312
|
+
from brawny.config.routing import resolve_default_group
|
|
313
|
+
|
|
314
|
+
rpc_group = resolve_default_group(config)
|
|
315
|
+
rpc_endpoints = config.rpc_groups[rpc_group].endpoints
|
|
316
|
+
chain_id = config.chain_id
|
|
317
|
+
|
|
318
|
+
if not rpc_endpoints:
|
|
319
|
+
click.echo("No RPC endpoints configured", err=True)
|
|
320
|
+
sys.exit(1)
|
|
321
|
+
|
|
322
|
+
# Create RPC manager with selected endpoints
|
|
323
|
+
rpc = RPCManager(
|
|
324
|
+
endpoints=rpc_endpoints,
|
|
325
|
+
timeout_seconds=config.rpc_timeout_seconds,
|
|
326
|
+
max_retries=config.rpc_max_retries,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# ContractSystem uses global ABI cache at ~/.brawny/abi_cache.db
|
|
330
|
+
contract_system = ContractSystem(rpc, config)
|
|
331
|
+
block_number = rpc.get_block_number()
|
|
332
|
+
|
|
333
|
+
# Set console context so Contract()/interface/web3 work in REPL
|
|
334
|
+
from brawny._context import ActiveContext, set_console_context
|
|
335
|
+
set_console_context(ActiveContext(
|
|
336
|
+
rpc=rpc,
|
|
337
|
+
contract_system=contract_system,
|
|
338
|
+
chain_id=chain_id,
|
|
339
|
+
network_name=None,
|
|
340
|
+
rpc_group=rpc_group,
|
|
341
|
+
))
|
|
342
|
+
|
|
343
|
+
# Initialize global singletons for Brownie-style access
|
|
344
|
+
from brawny.accounts import _init_accounts, accounts
|
|
345
|
+
from brawny.history import _init_history, history
|
|
346
|
+
from brawny.chain import _init_chain, chain
|
|
347
|
+
|
|
348
|
+
_init_accounts() # Lazy - keystores loaded via accounts.load()
|
|
349
|
+
_init_history()
|
|
350
|
+
_init_chain(rpc, chain_id)
|
|
351
|
+
|
|
352
|
+
# Brownie-style Contract function (capitalized to match brownie convention)
|
|
353
|
+
def Contract(address: str, abi: list | None = None):
|
|
354
|
+
"""Get a contract handle for the given address.
|
|
355
|
+
|
|
356
|
+
Mirrors brownie's Contract() interface.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
address: Contract address (0x...)
|
|
360
|
+
abi: Optional ABI override (if None, fetched from Etherscan)
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
ContractHandle with brownie-style interface
|
|
364
|
+
|
|
365
|
+
Example:
|
|
366
|
+
>>> keeper = Contract("0x1234...")
|
|
367
|
+
>>> keeper.canWork() # uses latest block
|
|
368
|
+
True
|
|
369
|
+
>>> keeper.canWork(block_identifier=21000000) # historical
|
|
370
|
+
False
|
|
371
|
+
>>> keeper.work.encode_input()
|
|
372
|
+
'0x322e78f1'
|
|
373
|
+
"""
|
|
374
|
+
# No block_identifier = uses "latest" (current block at call time)
|
|
375
|
+
return contract_system.handle(address=address, abi=abi)
|
|
376
|
+
|
|
377
|
+
# Brownie-style constants
|
|
378
|
+
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
|
|
379
|
+
ETH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" # Common placeholder
|
|
380
|
+
|
|
381
|
+
# Import Wei from api (DRY - shared implementation)
|
|
382
|
+
from brawny.api import Wei
|
|
383
|
+
|
|
384
|
+
# Build REPL namespace (mirrors brownie's __all__ exports)
|
|
385
|
+
from brawny.interfaces import interface
|
|
386
|
+
namespace = {
|
|
387
|
+
# Core (brownie-style names)
|
|
388
|
+
"Contract": Contract,
|
|
389
|
+
"chain": chain,
|
|
390
|
+
"accounts": accounts,
|
|
391
|
+
"history": history,
|
|
392
|
+
"Wei": Wei,
|
|
393
|
+
"web3": rpc.web3, # Direct Web3 instance (not proxy - console has direct rpc)
|
|
394
|
+
"interface": interface,
|
|
395
|
+
# Constants
|
|
396
|
+
"ZERO_ADDRESS": ZERO_ADDRESS,
|
|
397
|
+
"ETH_ADDRESS": ETH_ADDRESS,
|
|
398
|
+
# brawny specific
|
|
399
|
+
"rpc": rpc,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
# Clean banner
|
|
403
|
+
rpc_display = rpc_endpoints[0].split("@")[-1] if "@" in rpc_endpoints[0] else rpc_endpoints[0]
|
|
404
|
+
rpc_display = rpc_display.replace("https://", "").replace("http://", "")
|
|
405
|
+
|
|
406
|
+
click.echo()
|
|
407
|
+
click.echo(f" · chain {click.style(str(chain_id), fg='cyan')}")
|
|
408
|
+
click.echo(f" · block {click.style(str(block_number), fg='cyan')}")
|
|
409
|
+
click.echo(f" · rpc {click.style(rpc_display, dim=True)}")
|
|
410
|
+
click.echo()
|
|
411
|
+
|
|
412
|
+
# Re-enable logging for REPL (errors should be visible)
|
|
413
|
+
if not debug:
|
|
414
|
+
logging.disable(logging.NOTSET)
|
|
415
|
+
|
|
416
|
+
# Run Brownie-style REPL with colorized tracebacks
|
|
417
|
+
history_file = os.path.expanduser("~/.brawny_history")
|
|
418
|
+
namespace.setdefault("__builtins__", __builtins__)
|
|
419
|
+
|
|
420
|
+
shell = BrawnyConsole(namespace, history_file)
|
|
421
|
+
shell.interact(banner="", exitmsg="")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _start_anvil_fork(rpc_url: str, chain_id: int, port: int = 8545, block: int | None = None) -> str:
|
|
425
|
+
"""Start Anvil forking from given RPC.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
rpc_url: RPC endpoint to fork from
|
|
429
|
+
chain_id: Chain ID for the fork
|
|
430
|
+
port: Local port for Anvil (default 8545)
|
|
431
|
+
block: Optional block number to fork at
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
Local RPC URL (http://127.0.0.1:port)
|
|
435
|
+
"""
|
|
436
|
+
import socket
|
|
437
|
+
|
|
438
|
+
# Check if port is available (avoid cryptic errors)
|
|
439
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
440
|
+
if s.connect_ex(("127.0.0.1", port)) == 0:
|
|
441
|
+
click.echo(f"Port {port} already in use. Use --port to specify another.", err=True)
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
|
|
444
|
+
cmd = [
|
|
445
|
+
"anvil",
|
|
446
|
+
"--fork-url", rpc_url,
|
|
447
|
+
"--port", str(port),
|
|
448
|
+
"--chain-id", str(chain_id),
|
|
449
|
+
"--silent", # Suppress Anvil's own output
|
|
450
|
+
]
|
|
451
|
+
if block is not None:
|
|
452
|
+
cmd.extend(["--fork-block-number", str(block)])
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
proc = subprocess.Popen(
|
|
456
|
+
cmd,
|
|
457
|
+
stdout=subprocess.DEVNULL,
|
|
458
|
+
stderr=subprocess.DEVNULL,
|
|
459
|
+
)
|
|
460
|
+
except FileNotFoundError:
|
|
461
|
+
click.echo("Anvil not found. Install foundry: https://getfoundry.sh", err=True)
|
|
462
|
+
sys.exit(1)
|
|
463
|
+
|
|
464
|
+
# Register cleanup
|
|
465
|
+
atexit.register(proc.terminate)
|
|
466
|
+
|
|
467
|
+
# Poll until Anvil is ready (more reliable than sleep)
|
|
468
|
+
local_url = f"http://127.0.0.1:{port}"
|
|
469
|
+
import httpx
|
|
470
|
+
for _ in range(20): # 10 second timeout
|
|
471
|
+
if proc.poll() is not None:
|
|
472
|
+
click.echo("Anvil failed to start", err=True)
|
|
473
|
+
sys.exit(1)
|
|
474
|
+
try:
|
|
475
|
+
resp = httpx.post(
|
|
476
|
+
local_url,
|
|
477
|
+
json={"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1},
|
|
478
|
+
timeout=0.5,
|
|
479
|
+
)
|
|
480
|
+
if resp.status_code == 200:
|
|
481
|
+
break
|
|
482
|
+
except httpx.RequestError:
|
|
483
|
+
pass
|
|
484
|
+
time.sleep(0.5)
|
|
485
|
+
else:
|
|
486
|
+
click.echo("Anvil failed to start (timeout)", err=True)
|
|
487
|
+
proc.terminate()
|
|
488
|
+
sys.exit(1)
|
|
489
|
+
|
|
490
|
+
return local_url
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def register(main) -> None:
|
|
494
|
+
"""Register console command with main CLI."""
|
|
495
|
+
main.add_command(console)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Contract utility commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from brawny.cli.helpers import print_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
def contract() -> None:
|
|
16
|
+
"""Contract read utilities."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@contract.command("call")
|
|
21
|
+
@click.option("--address", "address", required=True, help="Contract address")
|
|
22
|
+
@click.option("--fn", "fn_signature", required=True, help="Function signature")
|
|
23
|
+
@click.option("--args", "args", multiple=True, help="Function arguments as strings (repeatable)")
|
|
24
|
+
@click.option("--args-json", "args_json", default=None, help="Function arguments as JSON array (for typed values)")
|
|
25
|
+
@click.option("--abi", "abi_file", default=None, help="Optional ABI JSON file")
|
|
26
|
+
@click.option("--block", "block_number", type=int, default=None, help="Block number")
|
|
27
|
+
@click.option("--format", "fmt", default="json", help="Output format (json or text)")
|
|
28
|
+
@click.option("--config", "config_path", default="./config.yaml", help="Path to config.yaml")
|
|
29
|
+
def contract_call(
|
|
30
|
+
address: str,
|
|
31
|
+
fn_signature: str,
|
|
32
|
+
args: tuple[str, ...],
|
|
33
|
+
args_json: str | None,
|
|
34
|
+
abi_file: str | None,
|
|
35
|
+
block_number: int | None,
|
|
36
|
+
fmt: str,
|
|
37
|
+
config_path: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Call a view function and print decoded output with type.
|
|
40
|
+
|
|
41
|
+
Arguments can be passed as strings with --args or as typed JSON with --args-json.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
|
|
45
|
+
brawny contract call --address 0x... --fn "balanceOf(address)" --args 0x...
|
|
46
|
+
|
|
47
|
+
brawny contract call --address 0x... --fn "transfer(address,uint256)" --args-json '["0x...", 1000]'
|
|
48
|
+
"""
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
from brawny.config import Config
|
|
52
|
+
from brawny.alerts.contracts import ContractSystem
|
|
53
|
+
from brawny.logging import get_logger, setup_logging
|
|
54
|
+
from brawny.model.enums import LogFormat
|
|
55
|
+
from brawny._rpc import RPCManager
|
|
56
|
+
|
|
57
|
+
if not config_path or not os.path.exists(config_path):
|
|
58
|
+
click.echo(
|
|
59
|
+
f"Config file is required for contract call and was not found: {config_path}",
|
|
60
|
+
err=True,
|
|
61
|
+
)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
config = Config.from_yaml(config_path)
|
|
65
|
+
config, overrides = config.apply_env_overrides()
|
|
66
|
+
|
|
67
|
+
log_level = os.environ.get("BRAWNY_LOG_LEVEL", "INFO")
|
|
68
|
+
setup_logging(log_level, LogFormat.JSON, config.chain_id)
|
|
69
|
+
log = get_logger(__name__)
|
|
70
|
+
log.info(
|
|
71
|
+
"config.loaded",
|
|
72
|
+
path=config_path,
|
|
73
|
+
overrides=overrides,
|
|
74
|
+
config=config.redacted_dict(),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
rpc = RPCManager.from_config(config)
|
|
78
|
+
# ContractSystem uses global ABI cache at ~/.brawny/abi_cache.db
|
|
79
|
+
contract_system = ContractSystem(rpc, config)
|
|
80
|
+
|
|
81
|
+
abi_data = None
|
|
82
|
+
if abi_file:
|
|
83
|
+
path = Path(abi_file)
|
|
84
|
+
if not path.exists():
|
|
85
|
+
click.echo(f"ABI file not found: {abi_file}", err=True)
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
try:
|
|
88
|
+
abi_data = json.loads(path.read_text())
|
|
89
|
+
except json.JSONDecodeError as e:
|
|
90
|
+
click.echo(f"Invalid ABI JSON: {e}", err=True)
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
block_id = block_number if block_number is not None else rpc.get_block_number(timeout=5)
|
|
94
|
+
|
|
95
|
+
handle = contract_system.handle(
|
|
96
|
+
address=address,
|
|
97
|
+
block_identifier=block_id,
|
|
98
|
+
abi=abi_data,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
caller = handle.fn(fn_signature)
|
|
102
|
+
|
|
103
|
+
# Parse arguments - prefer args_json if provided
|
|
104
|
+
if args_json:
|
|
105
|
+
if args:
|
|
106
|
+
click.echo("Cannot use both --args and --args-json", err=True)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
try:
|
|
109
|
+
parsed_args = json.loads(args_json)
|
|
110
|
+
if not isinstance(parsed_args, list):
|
|
111
|
+
click.echo("--args-json must be a JSON array", err=True)
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
except json.JSONDecodeError as e:
|
|
114
|
+
click.echo(f"Invalid --args-json: {e}", err=True)
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
else:
|
|
117
|
+
parsed_args = list(args)
|
|
118
|
+
|
|
119
|
+
value = caller.call(*parsed_args)
|
|
120
|
+
|
|
121
|
+
if fmt == "text":
|
|
122
|
+
click.echo(f"function: {fn_signature}")
|
|
123
|
+
click.echo(f"block: {block_id}")
|
|
124
|
+
click.echo(f"type: {type(value).__name__}")
|
|
125
|
+
click.echo(f"value: {value}")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
print_json(
|
|
129
|
+
{
|
|
130
|
+
"function": fn_signature,
|
|
131
|
+
"block": block_id,
|
|
132
|
+
"python_type": type(value).__name__,
|
|
133
|
+
"value": value,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def register(main) -> None:
|
|
139
|
+
main.add_command(contract)
|