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