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,364 @@
|
|
|
1
|
+
"""Function caller classes for contract interactions.
|
|
2
|
+
|
|
3
|
+
Provides FunctionCaller, OverloadedFunction, and ExplicitFunctionCaller classes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from eth_abi import encode as abi_encode, decode as abi_decode
|
|
11
|
+
|
|
12
|
+
from brawny._context import resolve_block_identifier
|
|
13
|
+
from brawny.alerts.encoded_call import EncodedCall, FunctionABI, ReturnValue
|
|
14
|
+
from brawny.alerts.errors import (
|
|
15
|
+
AmbiguousOverloadError,
|
|
16
|
+
ContractCallError,
|
|
17
|
+
OverloadMatchError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from brawny.alerts.contracts import ContractHandle
|
|
22
|
+
from brawny.jobs.base import TxReceipt
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FunctionCaller:
|
|
26
|
+
"""Callable wrapper for contract functions with Brownie-style interface.
|
|
27
|
+
|
|
28
|
+
Behavior varies by function state mutability:
|
|
29
|
+
- View/pure functions: __call__ executes eth_call, returns decoded value
|
|
30
|
+
- State-changing functions:
|
|
31
|
+
- With {"from": ...} as last arg: broadcasts transaction, returns receipt
|
|
32
|
+
- Without tx_params: returns EncodedCall (calldata with modifiers)
|
|
33
|
+
|
|
34
|
+
Methods:
|
|
35
|
+
- encode_input(*args): Get calldata without executing
|
|
36
|
+
- call(*args): Force eth_call simulation
|
|
37
|
+
- transact(*args, tx_params): Broadcast transaction
|
|
38
|
+
|
|
39
|
+
Usage (Brownie-style):
|
|
40
|
+
token.balanceOf(owner) # View - returns value
|
|
41
|
+
token.transfer(to, amount, {"from": accounts[0]}) # Broadcasts, returns receipt
|
|
42
|
+
token.transfer(to, amount) # Returns EncodedCall (calldata)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
contract: "ContractHandle",
|
|
48
|
+
function_abi: FunctionABI,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._contract = contract
|
|
51
|
+
self._abi = function_abi
|
|
52
|
+
|
|
53
|
+
def __call__(self, *args: Any, block_identifier: int | str | None = None) -> Any:
|
|
54
|
+
"""Call the function with automatic state mutability handling.
|
|
55
|
+
|
|
56
|
+
For view/pure functions: executes eth_call and returns decoded result.
|
|
57
|
+
For state-changing functions:
|
|
58
|
+
- With tx_params dict (Brownie-style): broadcasts and returns receipt
|
|
59
|
+
- Without tx_params: returns EncodedCall for deferred execution
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
*args: Function arguments. For state-changing functions, may include
|
|
63
|
+
tx_params dict as last argument (Brownie-style).
|
|
64
|
+
block_identifier: Optional block number/tag override for view calls.
|
|
65
|
+
If None, uses handle's block or "latest".
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
# View function - returns value directly
|
|
69
|
+
decimals = token.decimals() # 18
|
|
70
|
+
decimals = token.decimals(block_identifier=21000000) # at specific block
|
|
71
|
+
|
|
72
|
+
# State-changing function - Brownie-style (broadcasts immediately)
|
|
73
|
+
receipt = token.transfer(to, amount, {"from": accounts[0]})
|
|
74
|
+
|
|
75
|
+
# State-changing function - returns EncodedCall (for calldata)
|
|
76
|
+
calldata = vault.harvest() # "0x4641257d"
|
|
77
|
+
|
|
78
|
+
# EncodedCall can be used as calldata or with modifiers
|
|
79
|
+
result = vault.harvest().call() # Simulate
|
|
80
|
+
receipt = vault.harvest().transact({"from": "worker"}) # Broadcast
|
|
81
|
+
"""
|
|
82
|
+
if self._abi.is_state_changing:
|
|
83
|
+
# Check if last arg is tx_params dict (Brownie-style immediate broadcast)
|
|
84
|
+
if args and isinstance(args[-1], dict) and "from" in args[-1]:
|
|
85
|
+
tx_params = args[-1]
|
|
86
|
+
func_args = args[:-1]
|
|
87
|
+
calldata = self._encode_calldata(*func_args)
|
|
88
|
+
return self._contract._transact_with_calldata(calldata, tx_params, self._abi)
|
|
89
|
+
|
|
90
|
+
# No tx_params - return EncodedCall for calldata or deferred .transact()
|
|
91
|
+
calldata = self._encode_calldata(*args)
|
|
92
|
+
return EncodedCall(calldata, self._contract, self._abi)
|
|
93
|
+
|
|
94
|
+
# View/pure functions execute immediately
|
|
95
|
+
return self._execute_call(*args, block_identifier=block_identifier)
|
|
96
|
+
|
|
97
|
+
def encode_input(self, *args: Any) -> str:
|
|
98
|
+
"""Encode function calldata without executing.
|
|
99
|
+
|
|
100
|
+
Works for any function regardless of state mutability.
|
|
101
|
+
|
|
102
|
+
Usage:
|
|
103
|
+
data = vault.harvest.encode_input()
|
|
104
|
+
data = token.transfer.encode_input(recipient, amount)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Hex-encoded calldata string
|
|
108
|
+
"""
|
|
109
|
+
return self._encode_calldata(*args)
|
|
110
|
+
|
|
111
|
+
def call(self, *args: Any, block_identifier: int | str | None = None) -> Any:
|
|
112
|
+
"""Force eth_call simulation and return decoded result.
|
|
113
|
+
|
|
114
|
+
Works for any function regardless of state mutability.
|
|
115
|
+
Useful for simulating state-changing functions without broadcasting.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
*args: Function arguments
|
|
119
|
+
block_identifier: Optional block number/tag override.
|
|
120
|
+
If None, uses handle's block or "latest".
|
|
121
|
+
|
|
122
|
+
Usage:
|
|
123
|
+
# Simulate state-changing function
|
|
124
|
+
result = vault.harvest.call()
|
|
125
|
+
|
|
126
|
+
# Also works for view functions (same as direct call)
|
|
127
|
+
decimals = token.decimals.call()
|
|
128
|
+
|
|
129
|
+
# Query at specific block
|
|
130
|
+
decimals = token.decimals.call(block_identifier=21000000)
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Decoded return value from the function
|
|
134
|
+
"""
|
|
135
|
+
return self._execute_call(*args, block_identifier=block_identifier)
|
|
136
|
+
|
|
137
|
+
def transact(self, *args: Any) -> "TxReceipt":
|
|
138
|
+
"""Broadcast the transaction and wait for receipt.
|
|
139
|
+
|
|
140
|
+
Only works inside a @broadcast decorated function.
|
|
141
|
+
Transaction params dict must be the last argument.
|
|
142
|
+
|
|
143
|
+
Usage:
|
|
144
|
+
# No-arg function
|
|
145
|
+
receipt = vault.harvest.transact({"from": "yearn-worker"})
|
|
146
|
+
|
|
147
|
+
# With function args (tx_params is last)
|
|
148
|
+
receipt = token.transfer.transact(recipient, amount, {"from": "worker"})
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
*args: Function arguments, with tx_params dict as last arg
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Transaction receipt after confirmation
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
BroadcastNotAllowedError: If not in @broadcast context
|
|
158
|
+
ValueError: If tx_params dict not provided
|
|
159
|
+
"""
|
|
160
|
+
# Extract tx_params from last arg
|
|
161
|
+
if not args or not isinstance(args[-1], dict):
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"{self._abi.name}.transact() requires tx_params dict as last argument. "
|
|
164
|
+
f"Example: vault.{self._abi.name}.transact({{\"from\": \"signer\"}})"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
tx_params = args[-1]
|
|
168
|
+
func_args = args[:-1]
|
|
169
|
+
|
|
170
|
+
# Encode calldata and delegate to contract helper
|
|
171
|
+
calldata = self._encode_calldata(*func_args)
|
|
172
|
+
return self._contract._transact_with_calldata(calldata, tx_params, self._abi)
|
|
173
|
+
|
|
174
|
+
def _execute_call(self, *args: Any, block_identifier: int | str | None = None) -> Any:
|
|
175
|
+
"""Execute eth_call and decode result.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
*args: Function arguments
|
|
179
|
+
block_identifier: Optional block override. If None, uses handle's block or "latest".
|
|
180
|
+
"""
|
|
181
|
+
rpc = self._contract._system.rpc
|
|
182
|
+
|
|
183
|
+
# Encode call data
|
|
184
|
+
calldata = self._encode_calldata(*args)
|
|
185
|
+
|
|
186
|
+
# Build tx params for eth_call
|
|
187
|
+
tx_params = {
|
|
188
|
+
"to": self._contract.address,
|
|
189
|
+
"data": calldata,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# Resolve block using centralized 4-level precedence:
|
|
193
|
+
# 1. Explicit param 2. Handle's block 3. Check scope pin 4. "latest"
|
|
194
|
+
block_id = resolve_block_identifier(
|
|
195
|
+
explicit=block_identifier,
|
|
196
|
+
handle_block=self._contract._block_identifier,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Execute call with block pinning
|
|
200
|
+
try:
|
|
201
|
+
result = rpc.eth_call(tx_params, block_identifier=block_id)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
raise ContractCallError(
|
|
204
|
+
function_name=self._abi.name,
|
|
205
|
+
address=self._contract.address,
|
|
206
|
+
reason=str(e),
|
|
207
|
+
block_identifier=self._contract._block_identifier,
|
|
208
|
+
signature=self._abi.signature,
|
|
209
|
+
job_id=self._contract._job_id,
|
|
210
|
+
hook=self._contract._hook,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Convert result to hex string if bytes
|
|
214
|
+
if isinstance(result, bytes):
|
|
215
|
+
result = "0x" + result.hex()
|
|
216
|
+
|
|
217
|
+
# Decode result
|
|
218
|
+
return self._decode_result(result)
|
|
219
|
+
|
|
220
|
+
def _encode_calldata(self, *args: Any) -> str:
|
|
221
|
+
"""Encode function call data."""
|
|
222
|
+
if not self._abi.inputs:
|
|
223
|
+
return "0x" + self._abi.selector.hex()
|
|
224
|
+
|
|
225
|
+
# Convert floats to ints (supports scientific notation like 1e18)
|
|
226
|
+
converted_args = [int(a) if isinstance(a, float) else a for a in args]
|
|
227
|
+
types = [inp["type"] for inp in self._abi.inputs]
|
|
228
|
+
encoded_args = abi_encode(types, converted_args)
|
|
229
|
+
return "0x" + self._abi.selector.hex() + encoded_args.hex()
|
|
230
|
+
|
|
231
|
+
def _decode_result(self, result: str) -> Any:
|
|
232
|
+
"""Decode function return value with Brownie-compatible wrapping."""
|
|
233
|
+
if not self._abi.outputs:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
if result == "0x" or not result:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
if isinstance(result, str) and result.startswith("0x0x"):
|
|
240
|
+
result = "0x" + result[4:]
|
|
241
|
+
|
|
242
|
+
# Remove 0x prefix
|
|
243
|
+
data = bytes.fromhex(result[2:] if result.startswith("0x") else result)
|
|
244
|
+
if not data:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
types = [out["type"] for out in self._abi.outputs]
|
|
248
|
+
decoded = abi_decode(types, data)
|
|
249
|
+
|
|
250
|
+
# Single return value
|
|
251
|
+
if len(decoded) == 1:
|
|
252
|
+
# If it's a struct, wrap it so nested fields are accessible
|
|
253
|
+
if self._abi.outputs[0].get("components"):
|
|
254
|
+
return ReturnValue(decoded, self._abi.outputs)[0]
|
|
255
|
+
return decoded[0]
|
|
256
|
+
|
|
257
|
+
# Multiple return values: wrap in ReturnValue for named access
|
|
258
|
+
return ReturnValue(decoded, self._abi.outputs)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class OverloadedFunction:
|
|
262
|
+
"""Dispatcher for overloaded contract functions.
|
|
263
|
+
|
|
264
|
+
Resolves the correct overload based on argument count and delegates
|
|
265
|
+
to FunctionCaller. If multiple overloads match, raises AmbiguousOverloadError.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, contract: "ContractHandle", overloads: list[FunctionABI]) -> None:
|
|
269
|
+
self._contract = contract
|
|
270
|
+
self._overloads = overloads
|
|
271
|
+
|
|
272
|
+
def __call__(self, *args: Any) -> Any:
|
|
273
|
+
# Check if last arg is tx_params dict (Brownie-style)
|
|
274
|
+
if args and isinstance(args[-1], dict) and "from" in args[-1]:
|
|
275
|
+
tx_params = args[-1]
|
|
276
|
+
func_args = args[:-1]
|
|
277
|
+
caller = self._resolve(func_args) # Resolve based on func_args count
|
|
278
|
+
return caller(*func_args, tx_params) # FunctionCaller handles tx_params
|
|
279
|
+
else:
|
|
280
|
+
caller = self._resolve(args)
|
|
281
|
+
return caller(*args)
|
|
282
|
+
|
|
283
|
+
def call(self, *args: Any) -> Any:
|
|
284
|
+
caller = self._resolve(args)
|
|
285
|
+
return caller.call(*args)
|
|
286
|
+
|
|
287
|
+
def encode_input(self, *args: Any) -> str:
|
|
288
|
+
caller = self._resolve(args)
|
|
289
|
+
return caller.encode_input(*args)
|
|
290
|
+
|
|
291
|
+
def transact(self, *args: Any) -> "TxReceipt":
|
|
292
|
+
caller, func_args = self._resolve_for_transact(args)
|
|
293
|
+
return caller.transact(*func_args)
|
|
294
|
+
|
|
295
|
+
def _resolve(self, args: tuple[Any, ...]) -> FunctionCaller:
|
|
296
|
+
matches = [f for f in self._overloads if len(f.inputs) == len(args)]
|
|
297
|
+
if not matches:
|
|
298
|
+
candidates = [f.signature for f in self._overloads]
|
|
299
|
+
raise OverloadMatchError(
|
|
300
|
+
self._overloads[0].name,
|
|
301
|
+
len(args),
|
|
302
|
+
candidates,
|
|
303
|
+
)
|
|
304
|
+
if len(matches) > 1:
|
|
305
|
+
candidates = [f.signature for f in matches]
|
|
306
|
+
raise AmbiguousOverloadError(
|
|
307
|
+
self._overloads[0].name,
|
|
308
|
+
len(args),
|
|
309
|
+
candidates,
|
|
310
|
+
)
|
|
311
|
+
return FunctionCaller(self._contract, matches[0])
|
|
312
|
+
|
|
313
|
+
def _resolve_for_transact(
|
|
314
|
+
self, args: tuple[Any, ...]
|
|
315
|
+
) -> tuple[FunctionCaller, tuple[Any, ...]]:
|
|
316
|
+
if not args or not isinstance(args[-1], dict):
|
|
317
|
+
raise ValueError(
|
|
318
|
+
f"{self._overloads[0].name}.transact() requires tx_params dict as last argument."
|
|
319
|
+
)
|
|
320
|
+
func_args = args[:-1]
|
|
321
|
+
caller = self._resolve(func_args)
|
|
322
|
+
return caller, args
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class ExplicitFunctionCaller:
|
|
326
|
+
"""Explicit function caller for overloaded functions.
|
|
327
|
+
|
|
328
|
+
Usage:
|
|
329
|
+
token.fn("balanceOf(address)").call(owner)
|
|
330
|
+
token.fn("transfer(address,uint256)").transact(to, amount, {"from": "worker"})
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
def __init__(
|
|
334
|
+
self,
|
|
335
|
+
contract: "ContractHandle",
|
|
336
|
+
function_abi: FunctionABI,
|
|
337
|
+
) -> None:
|
|
338
|
+
self._contract = contract
|
|
339
|
+
self._abi = function_abi
|
|
340
|
+
|
|
341
|
+
def call(self, *args: Any) -> Any:
|
|
342
|
+
"""Execute eth_call and return decoded result.
|
|
343
|
+
|
|
344
|
+
Works for both view and state-changing functions (simulates the call).
|
|
345
|
+
"""
|
|
346
|
+
caller = FunctionCaller(self._contract, self._abi)
|
|
347
|
+
return caller._execute_call(*args)
|
|
348
|
+
|
|
349
|
+
def transact(self, *args: Any) -> "TxReceipt":
|
|
350
|
+
"""Broadcast the transaction and wait for receipt.
|
|
351
|
+
|
|
352
|
+
Only works inside a @broadcast decorated function.
|
|
353
|
+
Transaction params dict must be the last argument.
|
|
354
|
+
"""
|
|
355
|
+
caller = FunctionCaller(self._contract, self._abi)
|
|
356
|
+
return caller.transact(*args)
|
|
357
|
+
|
|
358
|
+
def encode_input(self, *args: Any) -> str:
|
|
359
|
+
"""Encode function call data without executing.
|
|
360
|
+
|
|
361
|
+
Returns hex-encoded calldata.
|
|
362
|
+
"""
|
|
363
|
+
caller = FunctionCaller(self._contract, self._abi)
|
|
364
|
+
return caller._encode_calldata(*args)
|
brawny/alerts/health.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Daemon health alerts with fingerprint-based deduplication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import Any, Callable, Literal
|
|
9
|
+
|
|
10
|
+
from brawny.logging import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_COOLDOWN_SECONDS = 1800
|
|
15
|
+
MAX_FIELD_LEN = 200
|
|
16
|
+
|
|
17
|
+
_last_fired: dict[str, datetime] = {}
|
|
18
|
+
_first_seen: dict[str, datetime] = {}
|
|
19
|
+
_suppressed_count: dict[str, int] = {}
|
|
20
|
+
_lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fingerprint(
|
|
24
|
+
component: str,
|
|
25
|
+
exc_type: str,
|
|
26
|
+
chain_id: int,
|
|
27
|
+
db_dialect: str | None = None,
|
|
28
|
+
fingerprint_key: str | None = None,
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Compute stable fingerprint for deduplication.
|
|
31
|
+
|
|
32
|
+
Default: component + exc_type + chain_id + db_dialect (message excluded for stability).
|
|
33
|
+
Override with fingerprint_key for explicit grouping (e.g., invariant names).
|
|
34
|
+
"""
|
|
35
|
+
if fingerprint_key:
|
|
36
|
+
key = f"{fingerprint_key}:{chain_id}:{db_dialect or 'unknown'}"
|
|
37
|
+
else:
|
|
38
|
+
key = f"{component}:{exc_type}:{chain_id}:{db_dialect or 'unknown'}"
|
|
39
|
+
return hashlib.sha1(key.encode()).hexdigest()[:12]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def health_alert(
|
|
43
|
+
*,
|
|
44
|
+
component: str,
|
|
45
|
+
chain_id: int,
|
|
46
|
+
error: Exception | str,
|
|
47
|
+
level: Literal["warning", "error", "critical"] = "error",
|
|
48
|
+
job_id: str | None = None,
|
|
49
|
+
intent_id: str | None = None,
|
|
50
|
+
claim_token: str | None = None,
|
|
51
|
+
status: str | None = None,
|
|
52
|
+
action: str | None = None,
|
|
53
|
+
db_dialect: str | None = None,
|
|
54
|
+
fingerprint_key: str | None = None,
|
|
55
|
+
force_send: bool = False,
|
|
56
|
+
send_fn: Callable[..., None] | None = None,
|
|
57
|
+
health_chat_id: str | None = None,
|
|
58
|
+
cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Send a daemon health alert with deduplication.
|
|
61
|
+
|
|
62
|
+
First occurrence: sends immediately (if level >= error).
|
|
63
|
+
Within cooldown: suppressed, count incremented.
|
|
64
|
+
After cooldown: sends summary with suppressed count + duration.
|
|
65
|
+
Warnings are logged only, never sent to Telegram.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
component: Component identifier (e.g., "brawny.tx.executor")
|
|
69
|
+
chain_id: Chain ID for context
|
|
70
|
+
error: Exception or error message
|
|
71
|
+
level: Severity level (warning, error, critical)
|
|
72
|
+
job_id: Optional job identifier
|
|
73
|
+
intent_id: Optional intent identifier
|
|
74
|
+
claim_token: Optional claim token for debugging stuckness
|
|
75
|
+
status: Optional intent status for debugging
|
|
76
|
+
action: Suggested remediation action
|
|
77
|
+
db_dialect: Database dialect for fingerprinting
|
|
78
|
+
fingerprint_key: Override default fingerprint for explicit grouping
|
|
79
|
+
force_send: Bypass deduplication entirely (e.g., startup alerts)
|
|
80
|
+
send_fn: Function to send alerts (e.g., alerts.send.send_health)
|
|
81
|
+
health_chat_id: Telegram chat ID for health alerts
|
|
82
|
+
cooldown_seconds: Deduplication window in seconds
|
|
83
|
+
"""
|
|
84
|
+
exc_type = type(error).__name__ if isinstance(error, Exception) else "Error"
|
|
85
|
+
message = str(error)[:MAX_FIELD_LEN]
|
|
86
|
+
fp = _fingerprint(component, exc_type, chain_id, db_dialect, fingerprint_key)
|
|
87
|
+
|
|
88
|
+
now = datetime.utcnow()
|
|
89
|
+
should_send = False
|
|
90
|
+
suppressed = 0
|
|
91
|
+
first_seen = now
|
|
92
|
+
|
|
93
|
+
if force_send:
|
|
94
|
+
should_send = True
|
|
95
|
+
else:
|
|
96
|
+
with _lock:
|
|
97
|
+
last = _last_fired.get(fp)
|
|
98
|
+
if last is None:
|
|
99
|
+
# First occurrence
|
|
100
|
+
should_send = True
|
|
101
|
+
_last_fired[fp] = now
|
|
102
|
+
_first_seen[fp] = now
|
|
103
|
+
_suppressed_count[fp] = 0
|
|
104
|
+
elif now - last > timedelta(seconds=cooldown_seconds):
|
|
105
|
+
# Cooldown expired, send summary
|
|
106
|
+
should_send = True
|
|
107
|
+
suppressed = _suppressed_count.get(fp, 0)
|
|
108
|
+
first_seen = _first_seen.get(fp, now)
|
|
109
|
+
_last_fired[fp] = now
|
|
110
|
+
_first_seen[fp] = now # Reset for next incident window
|
|
111
|
+
_suppressed_count[fp] = 0
|
|
112
|
+
else:
|
|
113
|
+
# Within cooldown, suppress
|
|
114
|
+
_suppressed_count[fp] = _suppressed_count.get(fp, 0) + 1
|
|
115
|
+
|
|
116
|
+
# Always log (use appropriate log level)
|
|
117
|
+
if level == "critical":
|
|
118
|
+
log_fn = logger.critical
|
|
119
|
+
elif level == "warning":
|
|
120
|
+
log_fn = logger.warning
|
|
121
|
+
else:
|
|
122
|
+
log_fn = logger.error
|
|
123
|
+
|
|
124
|
+
log_fn(
|
|
125
|
+
"daemon.health_alert",
|
|
126
|
+
component=component,
|
|
127
|
+
chain_id=chain_id,
|
|
128
|
+
error=message,
|
|
129
|
+
exc_type=exc_type,
|
|
130
|
+
level=level,
|
|
131
|
+
job_id=job_id,
|
|
132
|
+
intent_id=intent_id,
|
|
133
|
+
claim_token=claim_token,
|
|
134
|
+
status=status,
|
|
135
|
+
fingerprint=fp,
|
|
136
|
+
suppressed=not should_send,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Warnings are logged only, never sent to Telegram
|
|
140
|
+
if level == "warning":
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if not should_send:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if send_fn is None or health_chat_id is None:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# Build message (cap all fields)
|
|
150
|
+
lines = ["⚠️ Brawny Health Alert" if level == "error" else "🔴 CRITICAL Health Alert"]
|
|
151
|
+
lines.append(f"chain_id={chain_id}")
|
|
152
|
+
if job_id:
|
|
153
|
+
lines.append(f"job={job_id[:MAX_FIELD_LEN]}")
|
|
154
|
+
if intent_id:
|
|
155
|
+
lines.append(f"intent={intent_id[:12]}...")
|
|
156
|
+
if claim_token:
|
|
157
|
+
lines.append(f"claim_token={claim_token[:12]}...")
|
|
158
|
+
if status:
|
|
159
|
+
lines.append(f"status={status}")
|
|
160
|
+
lines.append(f"{exc_type}: {message}")
|
|
161
|
+
if suppressed > 0:
|
|
162
|
+
duration_seconds = (now - first_seen).total_seconds()
|
|
163
|
+
duration_str = f"{duration_seconds / 60:.0f}m" if duration_seconds >= 60 else f"{duration_seconds:.0f}s"
|
|
164
|
+
lines.append(f"(suppressed {suppressed}x over {duration_str})")
|
|
165
|
+
if action:
|
|
166
|
+
lines.append(f"Action: {action[:MAX_FIELD_LEN]}")
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
send_fn(chat_id=health_chat_id, text="\n".join(lines))
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.warning("health_alert.send_failed", error=str(e))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cleanup_stale_fingerprints(cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS) -> int:
|
|
175
|
+
"""Remove fingerprints older than 2x cooldown. Returns count removed."""
|
|
176
|
+
cutoff = datetime.utcnow() - timedelta(seconds=cooldown_seconds * 2)
|
|
177
|
+
removed = 0
|
|
178
|
+
with _lock:
|
|
179
|
+
stale = [fp for fp, ts in _last_fired.items() if ts < cutoff]
|
|
180
|
+
for fp in stale:
|
|
181
|
+
_last_fired.pop(fp, None)
|
|
182
|
+
_first_seen.pop(fp, None)
|
|
183
|
+
_suppressed_count.pop(fp, None)
|
|
184
|
+
removed += 1
|
|
185
|
+
return removed
|
brawny/alerts/routing.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Alert routing resolution.
|
|
2
|
+
|
|
3
|
+
Resolves named targets to chat IDs for Telegram alerts.
|
|
4
|
+
Could be extended for webhooks later.
|
|
5
|
+
|
|
6
|
+
Policy: Startup fails hard, runtime logs + drops.
|
|
7
|
+
- Startup validation catches typos during normal deployment
|
|
8
|
+
- Runtime unknown names log error and are skipped (not raised)
|
|
9
|
+
- This prevents hot-edited typos from crashing hook execution
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from brawny.logging import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_chat_id(s: str) -> bool:
|
|
20
|
+
"""Check if string looks like a raw Telegram chat ID.
|
|
21
|
+
|
|
22
|
+
Handles:
|
|
23
|
+
- Supergroups/channels: -100...
|
|
24
|
+
- Basic groups: negative ints -12345
|
|
25
|
+
- User IDs: positive ints
|
|
26
|
+
"""
|
|
27
|
+
return s.lstrip("-").isdigit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_targets(
|
|
31
|
+
target: str | list[str] | None,
|
|
32
|
+
chats: dict[str, str],
|
|
33
|
+
default: list[str],
|
|
34
|
+
*,
|
|
35
|
+
job_id: str | None = None,
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
"""Resolve target(s) to deduplicated list of chat IDs.
|
|
38
|
+
|
|
39
|
+
Policy: Startup fails hard, runtime logs + drops.
|
|
40
|
+
- Startup validation catches typos during normal deployment
|
|
41
|
+
- Runtime unknown names log error and are skipped (not raised)
|
|
42
|
+
- This prevents hot-edited typos from crashing hook execution
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
target: Chat name, raw ID, list of either, or None
|
|
46
|
+
chats: Name -> chat_id mapping from config
|
|
47
|
+
default: Default chat names/IDs if target is None
|
|
48
|
+
job_id: Optional job ID for logging context
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Deduplicated list of resolved chat IDs (preserves order).
|
|
52
|
+
Unknown names are logged and skipped, not raised.
|
|
53
|
+
"""
|
|
54
|
+
if target is None:
|
|
55
|
+
targets = default
|
|
56
|
+
elif isinstance(target, str):
|
|
57
|
+
targets = [target]
|
|
58
|
+
else:
|
|
59
|
+
targets = target
|
|
60
|
+
|
|
61
|
+
# Resolve names to IDs, dedupe while preserving order
|
|
62
|
+
seen: set[str] = set()
|
|
63
|
+
result: list[str] = []
|
|
64
|
+
|
|
65
|
+
for t in targets:
|
|
66
|
+
t = t.strip()
|
|
67
|
+
if not t:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
# Resolve: raw ID passes through, named chat looks up, unknown logs + skips
|
|
71
|
+
if is_chat_id(t):
|
|
72
|
+
chat_id = t
|
|
73
|
+
elif t in chats:
|
|
74
|
+
chat_id = chats[t]
|
|
75
|
+
else:
|
|
76
|
+
# Log and skip unknown names (don't crash hooks at runtime)
|
|
77
|
+
logger.error(
|
|
78
|
+
"alert.routing.unknown_target",
|
|
79
|
+
target=t,
|
|
80
|
+
job_id=job_id,
|
|
81
|
+
valid_names=sorted(chats.keys()),
|
|
82
|
+
)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if chat_id not in seen:
|
|
86
|
+
seen.add(chat_id)
|
|
87
|
+
result.append(chat_id)
|
|
88
|
+
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_targets(
|
|
93
|
+
target: str | list[str] | None,
|
|
94
|
+
valid_names: set[str],
|
|
95
|
+
) -> list[str]:
|
|
96
|
+
"""Validate that all non-ID targets are valid chat names.
|
|
97
|
+
|
|
98
|
+
Used at startup for hard failure on unknown names.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
target: Chat name, raw ID, list of either, or None
|
|
102
|
+
valid_names: Set of valid chat names from config
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
List of invalid names (empty if all valid)
|
|
106
|
+
"""
|
|
107
|
+
if target is None:
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
targets = [target] if isinstance(target, str) else target
|
|
111
|
+
invalid: list[str] = []
|
|
112
|
+
|
|
113
|
+
for t in targets:
|
|
114
|
+
t = t.strip()
|
|
115
|
+
if t and not is_chat_id(t) and t not in valid_names:
|
|
116
|
+
invalid.append(t)
|
|
117
|
+
|
|
118
|
+
return invalid
|