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,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
+ ]