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,635 @@
|
|
|
1
|
+
"""Contract handle for the Alerts extension.
|
|
2
|
+
|
|
3
|
+
Provides an ergonomic interface for interacting with contracts:
|
|
4
|
+
- Attribute-based function access: token.decimals()
|
|
5
|
+
- State mutability checks to prevent accidental state changes
|
|
6
|
+
- Explicit function access for overloads: token.fn("balanceOf(address)").call(owner)
|
|
7
|
+
|
|
8
|
+
Brownie-style interface:
|
|
9
|
+
- token.balanceOf(owner) - view functions return value directly
|
|
10
|
+
- token.transfer(to, amount, {"from": accounts[0]}) - broadcasts, returns receipt
|
|
11
|
+
- vault.harvest() - returns EncodedCall (calldata) if no tx_params
|
|
12
|
+
- vault.harvest.call() - forces eth_call (static simulation)
|
|
13
|
+
- vault.harvest.transact({"from": "signer"}) - deferred broadcast
|
|
14
|
+
- vault.harvest.encode_input() - returns calldata only
|
|
15
|
+
|
|
16
|
+
For events, use ctx.events (brownie-compatible):
|
|
17
|
+
ctx.events["Deposit"][0] # First Deposit event
|
|
18
|
+
ctx.events["Deposit"]["amount"] # Field access
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
from eth_abi import decode as abi_decode
|
|
28
|
+
from eth_utils import function_signature_to_4byte_selector, to_checksum_address
|
|
29
|
+
|
|
30
|
+
from brawny._context import resolve_block_identifier
|
|
31
|
+
from brawny.alerts.encoded_call import EncodedCall, FunctionABI, ReturnValue
|
|
32
|
+
from brawny.alerts.errors import (
|
|
33
|
+
AmbiguousOverloadError,
|
|
34
|
+
ContractCallError,
|
|
35
|
+
FunctionNotFoundError,
|
|
36
|
+
)
|
|
37
|
+
from brawny.alerts.function_caller import (
|
|
38
|
+
ExplicitFunctionCaller,
|
|
39
|
+
FunctionCaller,
|
|
40
|
+
OverloadedFunction,
|
|
41
|
+
)
|
|
42
|
+
from brawny.db.global_cache import GlobalABICache
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from brawny.config import Config
|
|
46
|
+
from brawny.jobs.base import TxReceipt
|
|
47
|
+
from brawny._rpc.manager import RPCManager
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ContractSystem:
|
|
51
|
+
"""Injected contract system for ABI resolution and eth_call execution.
|
|
52
|
+
|
|
53
|
+
Uses global ABI cache at ~/.brawny/abi_cache.db for persistent storage.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, rpc: "RPCManager", config: "Config") -> None:
|
|
57
|
+
self._rpc = rpc
|
|
58
|
+
self._config = config
|
|
59
|
+
self._abi_cache = GlobalABICache()
|
|
60
|
+
self._resolver = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def rpc(self) -> "RPCManager":
|
|
64
|
+
return self._rpc
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def config(self) -> "Config":
|
|
68
|
+
return self._config
|
|
69
|
+
|
|
70
|
+
def resolver(self):
|
|
71
|
+
if self._resolver is None:
|
|
72
|
+
from brawny.alerts.abi_resolver import ABIResolver
|
|
73
|
+
|
|
74
|
+
self._resolver = ABIResolver(self._rpc, self._config, self._abi_cache)
|
|
75
|
+
return self._resolver
|
|
76
|
+
|
|
77
|
+
def handle(
|
|
78
|
+
self,
|
|
79
|
+
address: str,
|
|
80
|
+
receipt: "TxReceipt | None" = None,
|
|
81
|
+
block_identifier: int | None = None,
|
|
82
|
+
job_id: str | None = None,
|
|
83
|
+
hook: str | None = None,
|
|
84
|
+
abi: list[dict[str, Any]] | None = None,
|
|
85
|
+
) -> "ContractHandle":
|
|
86
|
+
return ContractHandle(
|
|
87
|
+
address=address,
|
|
88
|
+
receipt=receipt,
|
|
89
|
+
block_identifier=block_identifier,
|
|
90
|
+
system=self,
|
|
91
|
+
job_id=job_id,
|
|
92
|
+
hook=hook,
|
|
93
|
+
abi=abi,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ContractHandle:
|
|
98
|
+
"""Handle for interacting with a contract.
|
|
99
|
+
|
|
100
|
+
Provides:
|
|
101
|
+
- Attribute access for function calls: token.decimals()
|
|
102
|
+
- Explicit function access: token.fn("balanceOf(address)").call(owner)
|
|
103
|
+
|
|
104
|
+
For events, use ctx.events (brownie-style):
|
|
105
|
+
ctx.events["Deposit"][0] # First Deposit event
|
|
106
|
+
ctx.events["Deposit"]["amount"] # Field access
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
address: str,
|
|
112
|
+
receipt: "TxReceipt | None" = None,
|
|
113
|
+
block_identifier: int | None = None,
|
|
114
|
+
system: ContractSystem | None = None,
|
|
115
|
+
job_id: str | None = None,
|
|
116
|
+
hook: str | None = None,
|
|
117
|
+
abi: list[dict[str, Any]] | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Initialize contract handle.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
address: Contract address
|
|
123
|
+
receipt: Transaction receipt (for event access)
|
|
124
|
+
block_identifier: Block number for eth_calls
|
|
125
|
+
abi: Optional pre-resolved ABI (if None, will be resolved)
|
|
126
|
+
"""
|
|
127
|
+
if system is None:
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
"Contract system not configured. Initialize ContractSystem and "
|
|
130
|
+
"pass it into contexts before using ContractHandle."
|
|
131
|
+
)
|
|
132
|
+
self._address = to_checksum_address(address)
|
|
133
|
+
self._receipt = receipt
|
|
134
|
+
self._block_identifier = block_identifier
|
|
135
|
+
self._system = system
|
|
136
|
+
self._job_id = job_id
|
|
137
|
+
self._hook = hook
|
|
138
|
+
self._abi_list = abi
|
|
139
|
+
self._functions: dict[str, list[FunctionABI]] | None = None
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def address(self) -> str:
|
|
143
|
+
"""Contract address (checksummed)."""
|
|
144
|
+
return self._address
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def abi(self) -> list[dict[str, Any]]:
|
|
148
|
+
"""Contract ABI."""
|
|
149
|
+
self._ensure_abi()
|
|
150
|
+
return self._abi_list # type: ignore
|
|
151
|
+
|
|
152
|
+
def _ensure_abi(self) -> None:
|
|
153
|
+
"""Ensure ABI is loaded."""
|
|
154
|
+
if self._abi_list is not None:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
resolver = self._system.resolver()
|
|
158
|
+
resolved = resolver.resolve(self._address)
|
|
159
|
+
self._abi_list = resolved.abi
|
|
160
|
+
|
|
161
|
+
def _ensure_functions_parsed(self) -> None:
|
|
162
|
+
"""Ensure function ABIs are parsed."""
|
|
163
|
+
if self._functions is not None:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
self._ensure_abi()
|
|
167
|
+
self._functions = {}
|
|
168
|
+
|
|
169
|
+
for item in self._abi_list: # type: ignore
|
|
170
|
+
if item.get("type") != "function":
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
name = item.get("name", "")
|
|
174
|
+
if not name:
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
inputs = item.get("inputs", [])
|
|
178
|
+
outputs = item.get("outputs", [])
|
|
179
|
+
state_mutability = item.get("stateMutability", "nonpayable")
|
|
180
|
+
|
|
181
|
+
# Build signature
|
|
182
|
+
input_types = [inp["type"] for inp in inputs]
|
|
183
|
+
signature = f"{name}({','.join(input_types)})"
|
|
184
|
+
|
|
185
|
+
# Calculate selector
|
|
186
|
+
selector = function_signature_to_4byte_selector(signature)
|
|
187
|
+
|
|
188
|
+
func_abi = FunctionABI(
|
|
189
|
+
name=name,
|
|
190
|
+
inputs=inputs,
|
|
191
|
+
outputs=outputs,
|
|
192
|
+
state_mutability=state_mutability,
|
|
193
|
+
signature=signature,
|
|
194
|
+
selector=selector,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if name not in self._functions:
|
|
198
|
+
self._functions[name] = []
|
|
199
|
+
self._functions[name].append(func_abi)
|
|
200
|
+
|
|
201
|
+
def __getattr__(self, name: str) -> FunctionCaller:
|
|
202
|
+
"""Get function caller by attribute name.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
FunctionNotFoundError: If function not in ABI
|
|
206
|
+
AmbiguousOverloadError: If multiple overloads match
|
|
207
|
+
"""
|
|
208
|
+
# Skip special attributes
|
|
209
|
+
if name.startswith("_"):
|
|
210
|
+
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
|
|
211
|
+
|
|
212
|
+
self._ensure_functions_parsed()
|
|
213
|
+
|
|
214
|
+
if name not in self._functions:
|
|
215
|
+
available = list(self._functions.keys()) if self._functions else []
|
|
216
|
+
raise FunctionNotFoundError(name, self._address, available)
|
|
217
|
+
|
|
218
|
+
overloads = self._functions[name]
|
|
219
|
+
|
|
220
|
+
# If only one overload, return it directly
|
|
221
|
+
if len(overloads) == 1:
|
|
222
|
+
return FunctionCaller(self, overloads[0])
|
|
223
|
+
|
|
224
|
+
# Multiple overloads - resolve by argument count at call time
|
|
225
|
+
return OverloadedFunction(self, overloads)
|
|
226
|
+
|
|
227
|
+
def fn(self, signature: str) -> ExplicitFunctionCaller:
|
|
228
|
+
"""Get explicit function caller by signature.
|
|
229
|
+
|
|
230
|
+
Use this for overloaded functions or when explicit control is needed.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
signature: Function signature like "balanceOf(address)" or just "transfer"
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
ExplicitFunctionCaller for the function
|
|
237
|
+
|
|
238
|
+
Usage:
|
|
239
|
+
token.fn("balanceOf(address)").call(owner)
|
|
240
|
+
token.fn("transfer(address,uint256)").transact(to, amount, {"from": "worker"})
|
|
241
|
+
"""
|
|
242
|
+
self._ensure_functions_parsed()
|
|
243
|
+
|
|
244
|
+
# Check if it's a full signature or just a name
|
|
245
|
+
if "(" in signature:
|
|
246
|
+
# Full signature - find exact match
|
|
247
|
+
for overloads in self._functions.values():
|
|
248
|
+
for func in overloads:
|
|
249
|
+
if func.signature == signature:
|
|
250
|
+
return ExplicitFunctionCaller(self, func)
|
|
251
|
+
|
|
252
|
+
# Try parsing and matching
|
|
253
|
+
match = re.match(r"(\w+)\((.*)\)", signature)
|
|
254
|
+
if match:
|
|
255
|
+
name = match.group(1)
|
|
256
|
+
if name in self._functions:
|
|
257
|
+
for func in self._functions[name]:
|
|
258
|
+
if func.signature == signature:
|
|
259
|
+
return ExplicitFunctionCaller(self, func)
|
|
260
|
+
|
|
261
|
+
raise FunctionNotFoundError(
|
|
262
|
+
signature, self._address, self._get_all_signatures()
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
# Just a name - must have exactly one overload
|
|
266
|
+
name = signature
|
|
267
|
+
if name not in self._functions:
|
|
268
|
+
raise FunctionNotFoundError(
|
|
269
|
+
name, self._address, list(self._functions.keys())
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
overloads = self._functions[name]
|
|
273
|
+
if len(overloads) > 1:
|
|
274
|
+
raise AmbiguousOverloadError(
|
|
275
|
+
name, -1, [f.signature for f in overloads]
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return ExplicitFunctionCaller(self, overloads[0])
|
|
279
|
+
|
|
280
|
+
def _get_all_signatures(self) -> list[str]:
|
|
281
|
+
"""Get all function signatures in the ABI."""
|
|
282
|
+
sigs = []
|
|
283
|
+
for overloads in self._functions.values():
|
|
284
|
+
for func in overloads:
|
|
285
|
+
sigs.append(func.signature)
|
|
286
|
+
return sigs
|
|
287
|
+
|
|
288
|
+
def __dir__(self) -> list[str]:
|
|
289
|
+
"""Return available attributes for tab completion."""
|
|
290
|
+
self._ensure_functions_parsed()
|
|
291
|
+
return [*super().__dir__(), *(self._functions or [])]
|
|
292
|
+
|
|
293
|
+
def _call_with_calldata(self, calldata: str, abi: FunctionABI) -> Any:
|
|
294
|
+
"""Execute eth_call with pre-encoded calldata.
|
|
295
|
+
|
|
296
|
+
Used by EncodedCall.call() and FunctionCaller.call().
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
calldata: Hex-encoded calldata
|
|
300
|
+
abi: Function ABI for result decoding
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Decoded return value
|
|
304
|
+
"""
|
|
305
|
+
rpc = self._system.rpc
|
|
306
|
+
|
|
307
|
+
tx_params = {
|
|
308
|
+
"to": self._address,
|
|
309
|
+
"data": calldata,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
# Resolve block using centralized 4-level precedence:
|
|
313
|
+
# 1. Explicit param (N/A here) 2. Handle's block 3. Check scope pin 4. "latest"
|
|
314
|
+
block_id = resolve_block_identifier(
|
|
315
|
+
explicit=None, # _call_with_calldata doesn't accept explicit block param
|
|
316
|
+
handle_block=self._block_identifier,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
result = rpc.eth_call(tx_params, block_identifier=block_id)
|
|
321
|
+
except Exception as e:
|
|
322
|
+
raise ContractCallError(
|
|
323
|
+
function_name=abi.name,
|
|
324
|
+
address=self._address,
|
|
325
|
+
reason=str(e),
|
|
326
|
+
block_identifier=self._block_identifier,
|
|
327
|
+
signature=abi.signature,
|
|
328
|
+
job_id=self._job_id,
|
|
329
|
+
hook=self._hook,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# Convert result to hex string if bytes
|
|
333
|
+
if isinstance(result, bytes):
|
|
334
|
+
result = "0x" + result.hex()
|
|
335
|
+
|
|
336
|
+
# Decode result
|
|
337
|
+
return self._decode_result(result, abi)
|
|
338
|
+
|
|
339
|
+
def _decode_result(self, result: str, abi: FunctionABI) -> Any:
|
|
340
|
+
"""Decode function return value with Brownie-compatible wrapping."""
|
|
341
|
+
if not abi.outputs:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
if result == "0x" or not result:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
if isinstance(result, str) and result.startswith("0x0x"):
|
|
348
|
+
result = "0x" + result[4:]
|
|
349
|
+
|
|
350
|
+
# Remove 0x prefix
|
|
351
|
+
data = bytes.fromhex(result[2:] if result.startswith("0x") else result)
|
|
352
|
+
if not data:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
types = [out["type"] for out in abi.outputs]
|
|
356
|
+
decoded = abi_decode(types, data)
|
|
357
|
+
|
|
358
|
+
# Single return value
|
|
359
|
+
if len(decoded) == 1:
|
|
360
|
+
# If it's a struct, wrap it so nested fields are accessible
|
|
361
|
+
if abi.outputs[0].get("components"):
|
|
362
|
+
return ReturnValue(decoded, abi.outputs)[0]
|
|
363
|
+
return decoded[0]
|
|
364
|
+
|
|
365
|
+
# Multiple return values: wrap in ReturnValue for named access
|
|
366
|
+
return ReturnValue(decoded, abi.outputs)
|
|
367
|
+
|
|
368
|
+
def _transact_with_calldata(
|
|
369
|
+
self,
|
|
370
|
+
calldata: str,
|
|
371
|
+
tx_params: dict[str, Any],
|
|
372
|
+
abi: FunctionABI,
|
|
373
|
+
) -> "TxReceipt":
|
|
374
|
+
"""Broadcast a transaction with pre-encoded calldata.
|
|
375
|
+
|
|
376
|
+
Works in:
|
|
377
|
+
- Script context (uses TransactionBroadcaster)
|
|
378
|
+
- @broadcast decorator context (uses keystore)
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
calldata: Hex-encoded calldata
|
|
382
|
+
tx_params: Transaction parameters with 'from' key
|
|
383
|
+
abi: Function ABI for error messages
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Transaction receipt after confirmation
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
RuntimeError: If not in script or @broadcast context
|
|
390
|
+
SignerNotFoundError: If 'from' address not in keystore
|
|
391
|
+
"""
|
|
392
|
+
from brawny.scripting import (
|
|
393
|
+
broadcast_enabled,
|
|
394
|
+
get_broadcast_context,
|
|
395
|
+
BroadcastNotAllowedError,
|
|
396
|
+
SignerNotFoundError,
|
|
397
|
+
TransactionRevertedError,
|
|
398
|
+
TransactionTimeoutError,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Validate 'from' parameter
|
|
402
|
+
if "from" not in tx_params:
|
|
403
|
+
raise ValueError(
|
|
404
|
+
f".transact() requires 'from' key in tx_params. "
|
|
405
|
+
f"Example: vault.{abi.name}.transact({{\"from\": \"signer\"}})"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
sender_obj = tx_params["from"]
|
|
409
|
+
|
|
410
|
+
# Extract private key if Account instance
|
|
411
|
+
private_key = None
|
|
412
|
+
from brawny.accounts import Account
|
|
413
|
+
if isinstance(sender_obj, Account):
|
|
414
|
+
from_address = sender_obj.address
|
|
415
|
+
private_key = sender_obj._private_key
|
|
416
|
+
else:
|
|
417
|
+
from_address = str(sender_obj)
|
|
418
|
+
|
|
419
|
+
# Try script context first (TransactionBroadcaster)
|
|
420
|
+
try:
|
|
421
|
+
from brawny.script_tx import _get_broadcaster
|
|
422
|
+
broadcaster = _get_broadcaster()
|
|
423
|
+
return broadcaster.transact(
|
|
424
|
+
sender=to_checksum_address(from_address),
|
|
425
|
+
to=self._address,
|
|
426
|
+
data=calldata,
|
|
427
|
+
value=tx_params.get("value", 0),
|
|
428
|
+
gas_limit=tx_params.get("gas"),
|
|
429
|
+
gas_price=tx_params.get("gasPrice"),
|
|
430
|
+
max_fee_per_gas=tx_params.get("maxFeePerGas"),
|
|
431
|
+
max_priority_fee_per_gas=tx_params.get("maxPriorityFeePerGas"),
|
|
432
|
+
nonce=tx_params.get("nonce"),
|
|
433
|
+
private_key=private_key,
|
|
434
|
+
)
|
|
435
|
+
except RuntimeError:
|
|
436
|
+
pass # Not in script context, try @broadcast
|
|
437
|
+
|
|
438
|
+
# Fall back to @broadcast context
|
|
439
|
+
if not broadcast_enabled():
|
|
440
|
+
raise RuntimeError(
|
|
441
|
+
f"transact() requires script context or @broadcast decorator. "
|
|
442
|
+
f"Use 'brawny script run' or wrap function with @broadcast."
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
ctx = get_broadcast_context()
|
|
446
|
+
if ctx is None:
|
|
447
|
+
raise BroadcastNotAllowedError(abi.name, reason="broadcast context not available")
|
|
448
|
+
|
|
449
|
+
# Resolve signer address via keystore (for @broadcast mode)
|
|
450
|
+
keystore = ctx.keystore
|
|
451
|
+
if keystore is None and private_key is None:
|
|
452
|
+
raise SignerNotFoundError(from_address)
|
|
453
|
+
if private_key is None:
|
|
454
|
+
try:
|
|
455
|
+
from_address = keystore.get_address(str(sender_obj))
|
|
456
|
+
except Exception as e:
|
|
457
|
+
raise SignerNotFoundError(str(sender_obj)) from e
|
|
458
|
+
|
|
459
|
+
rpc = self._system.rpc
|
|
460
|
+
|
|
461
|
+
# Build transaction
|
|
462
|
+
tx: dict[str, Any] = {
|
|
463
|
+
"from": to_checksum_address(from_address),
|
|
464
|
+
"to": to_checksum_address(self._address),
|
|
465
|
+
"data": calldata,
|
|
466
|
+
"chainId": int(tx_params.get("chainId") or self._system.config.chain_id),
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
# Add optional parameters
|
|
470
|
+
def _parse_int(value: Any, field: str) -> int:
|
|
471
|
+
if isinstance(value, int):
|
|
472
|
+
return value
|
|
473
|
+
if isinstance(value, str):
|
|
474
|
+
return int(value, 0)
|
|
475
|
+
raise ValueError(f"Invalid {field} type: {type(value).__name__}")
|
|
476
|
+
|
|
477
|
+
if "value" in tx_params:
|
|
478
|
+
tx["value"] = _parse_int(tx_params["value"], "value")
|
|
479
|
+
if "gas" in tx_params:
|
|
480
|
+
tx["gas"] = _parse_int(tx_params["gas"], "gas")
|
|
481
|
+
if "gasPrice" in tx_params:
|
|
482
|
+
tx["gasPrice"] = _parse_int(tx_params["gasPrice"], "gasPrice")
|
|
483
|
+
if "maxFeePerGas" in tx_params:
|
|
484
|
+
tx["maxFeePerGas"] = _parse_int(tx_params["maxFeePerGas"], "maxFeePerGas")
|
|
485
|
+
if "maxPriorityFeePerGas" in tx_params:
|
|
486
|
+
tx["maxPriorityFeePerGas"] = _parse_int(
|
|
487
|
+
tx_params["maxPriorityFeePerGas"],
|
|
488
|
+
"maxPriorityFeePerGas",
|
|
489
|
+
)
|
|
490
|
+
if "nonce" in tx_params:
|
|
491
|
+
tx["nonce"] = _parse_int(tx_params["nonce"], "nonce")
|
|
492
|
+
|
|
493
|
+
# Auto-estimate gas if not provided
|
|
494
|
+
if "gas" not in tx:
|
|
495
|
+
try:
|
|
496
|
+
tx["gas"] = rpc.estimate_gas(tx)
|
|
497
|
+
except Exception as e:
|
|
498
|
+
raise ContractCallError(
|
|
499
|
+
function_name=abi.name,
|
|
500
|
+
address=self._address,
|
|
501
|
+
reason=f"Gas estimation failed: {e}",
|
|
502
|
+
signature=abi.signature,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Auto-fetch nonce if not provided
|
|
506
|
+
if "nonce" not in tx:
|
|
507
|
+
tx["nonce"] = rpc.get_transaction_count(from_address, "pending")
|
|
508
|
+
|
|
509
|
+
# Default gas price if no fees provided
|
|
510
|
+
if (
|
|
511
|
+
"gasPrice" not in tx
|
|
512
|
+
and "maxFeePerGas" not in tx
|
|
513
|
+
and "maxPriorityFeePerGas" not in tx
|
|
514
|
+
):
|
|
515
|
+
tx["gasPrice"] = rpc.get_gas_price()
|
|
516
|
+
|
|
517
|
+
if "maxFeePerGas" in tx or "maxPriorityFeePerGas" in tx:
|
|
518
|
+
tx["type"] = 2
|
|
519
|
+
|
|
520
|
+
# Sign and broadcast transaction
|
|
521
|
+
if private_key is not None:
|
|
522
|
+
# Sign with Account's private key directly
|
|
523
|
+
from eth_account import Account as EthAccount
|
|
524
|
+
signed = EthAccount.sign_transaction(tx, private_key)
|
|
525
|
+
else:
|
|
526
|
+
signed = keystore.sign_transaction(tx, str(sender_obj))
|
|
527
|
+
raw_tx = getattr(signed, "raw_transaction", None) or signed.rawTransaction
|
|
528
|
+
tx_hash = rpc.send_raw_transaction(raw_tx)
|
|
529
|
+
|
|
530
|
+
# Wait for receipt
|
|
531
|
+
deadline = time.time() + ctx.timeout_seconds
|
|
532
|
+
receipt = None
|
|
533
|
+
while time.time() < deadline:
|
|
534
|
+
receipt = rpc.get_transaction_receipt(tx_hash)
|
|
535
|
+
if receipt is not None:
|
|
536
|
+
break
|
|
537
|
+
time.sleep(ctx.poll_interval_seconds)
|
|
538
|
+
|
|
539
|
+
if receipt is None:
|
|
540
|
+
raise TransactionTimeoutError(tx_hash, ctx.timeout_seconds)
|
|
541
|
+
|
|
542
|
+
status = receipt.get("status", 1)
|
|
543
|
+
if status == 0:
|
|
544
|
+
raise TransactionRevertedError(tx_hash)
|
|
545
|
+
|
|
546
|
+
tx_hash_val = receipt.get("transactionHash")
|
|
547
|
+
if hasattr(tx_hash_val, "hex"):
|
|
548
|
+
tx_hash_val = f"0x{tx_hash_val.hex()}"
|
|
549
|
+
block_hash = receipt.get("blockHash")
|
|
550
|
+
if hasattr(block_hash, "hex"):
|
|
551
|
+
block_hash = f"0x{block_hash.hex()}"
|
|
552
|
+
|
|
553
|
+
from brawny.jobs.base import TxReceipt
|
|
554
|
+
|
|
555
|
+
return TxReceipt(
|
|
556
|
+
transaction_hash=tx_hash_val,
|
|
557
|
+
block_number=receipt.get("blockNumber"),
|
|
558
|
+
block_hash=block_hash,
|
|
559
|
+
status=status,
|
|
560
|
+
gas_used=receipt.get("gasUsed", 0),
|
|
561
|
+
logs=list(receipt.get("logs", [])),
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def __repr__(self) -> str:
|
|
565
|
+
return f"ContractHandle({self._address})"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class SimpleContractFactory:
|
|
569
|
+
"""ContractFactory implementation wrapping ContractSystem.
|
|
570
|
+
|
|
571
|
+
Provides block-aware contract access per OE7:
|
|
572
|
+
- at(): Get handle reading at 'latest'. Use in build/alerts.
|
|
573
|
+
- at_block(): Get handle pinned to specific block. Use in check().
|
|
574
|
+
- with_abi(): Get handle with explicit ABI.
|
|
575
|
+
|
|
576
|
+
Factory stays dumb:
|
|
577
|
+
- Does not silently switch endpoints/groups
|
|
578
|
+
- Does not mutate global caches
|
|
579
|
+
- Is deterministic under a given rpc + abi_resolver
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
def __init__(self, system: ContractSystem) -> None:
|
|
583
|
+
self._system = system
|
|
584
|
+
|
|
585
|
+
def at(self, name: str, address: str) -> ContractHandle:
|
|
586
|
+
"""Get contract handle, reads at 'latest'. Use in build/alerts.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
name: Contract name (for ABI lookup, currently unused)
|
|
590
|
+
address: Contract address
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
ContractHandle reading at 'latest'
|
|
594
|
+
"""
|
|
595
|
+
return self._system.handle(address=address, block_identifier=None)
|
|
596
|
+
|
|
597
|
+
def at_block(self, name: str, address: str, block: int) -> ContractHandle:
|
|
598
|
+
"""Get contract handle pinned to specific block. Use in check().
|
|
599
|
+
|
|
600
|
+
The block is baked into the handle - it cannot forget the pinning.
|
|
601
|
+
This prevents TOCTOU bugs where check() reads at inconsistent blocks.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
name: Contract name (for ABI lookup, currently unused)
|
|
605
|
+
address: Contract address
|
|
606
|
+
block: Block number to pin reads to
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
ContractHandle with all reads pinned to the specified block
|
|
610
|
+
"""
|
|
611
|
+
return self._system.handle(address=address, block_identifier=block)
|
|
612
|
+
|
|
613
|
+
def with_abi(self, address: str, abi: list[Any]) -> ContractHandle:
|
|
614
|
+
"""Get contract handle with explicit ABI.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
address: Contract address
|
|
618
|
+
abi: Explicit ABI to use
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
ContractHandle with the provided ABI
|
|
622
|
+
"""
|
|
623
|
+
return self._system.handle(address=address, abi=abi)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
__all__ = [
|
|
627
|
+
"ContractSystem",
|
|
628
|
+
"ContractHandle",
|
|
629
|
+
"SimpleContractFactory",
|
|
630
|
+
"FunctionCaller",
|
|
631
|
+
"ExplicitFunctionCaller",
|
|
632
|
+
"EncodedCall",
|
|
633
|
+
"FunctionABI",
|
|
634
|
+
"ReturnValue",
|
|
635
|
+
]
|