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
brawny/api.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""Public API helpers for implicit context.
|
|
2
|
+
|
|
3
|
+
These functions provide a Flask-like implicit context pattern for job hooks.
|
|
4
|
+
Import and use them directly - they read from contextvars set by the framework.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from brawny import Contract, trigger, intent, block
|
|
8
|
+
|
|
9
|
+
@job(signer="harvester")
|
|
10
|
+
class VaultHarvester(Job):
|
|
11
|
+
def check(self):
|
|
12
|
+
vault = Contract("vault")
|
|
13
|
+
if vault.totalAssets() > 10e18:
|
|
14
|
+
return trigger(reason="Harvest time", tx_required=True)
|
|
15
|
+
|
|
16
|
+
def build_intent(self, trig):
|
|
17
|
+
vault = Contract("vault")
|
|
18
|
+
return intent(
|
|
19
|
+
to_address=vault.address,
|
|
20
|
+
data=vault.harvest.encode_input(),
|
|
21
|
+
) # signer inherited from @job decorator
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from brawny._context import get_current_job, get_job_context
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from brawny.alerts.contracts import ContractHandle
|
|
32
|
+
from brawny.model.types import Trigger, TxIntentSpec
|
|
33
|
+
from brawny._rpc.gas import GasQuote
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def trigger(
|
|
37
|
+
reason: str,
|
|
38
|
+
tx_required: bool = True,
|
|
39
|
+
idempotency_parts: list[str | int | bytes] | None = None,
|
|
40
|
+
) -> Trigger:
|
|
41
|
+
"""Create a Trigger to signal that action is needed.
|
|
42
|
+
|
|
43
|
+
Only valid inside check().
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
reason: Human-readable description of why we're triggering
|
|
47
|
+
(auto-included in intent.metadata["reason"])
|
|
48
|
+
tx_required: Whether this trigger needs a transaction (default True)
|
|
49
|
+
idempotency_parts: Optional list for deduplication
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Trigger instance
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
LookupError: If called outside check()
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
if profit > threshold:
|
|
59
|
+
return trigger(reason=f"Harvesting {profit} profit")
|
|
60
|
+
"""
|
|
61
|
+
from brawny.model.types import Trigger as TriggerType
|
|
62
|
+
|
|
63
|
+
# Verify we're in a job context (check() or build_tx())
|
|
64
|
+
get_job_context()
|
|
65
|
+
|
|
66
|
+
return TriggerType(
|
|
67
|
+
reason=reason,
|
|
68
|
+
tx_required=tx_required,
|
|
69
|
+
idempotency_parts=idempotency_parts or [],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def intent(
|
|
74
|
+
to_address: str,
|
|
75
|
+
data: str | None = None,
|
|
76
|
+
value_wei: str | int | float = 0,
|
|
77
|
+
gas_limit: int | float | None = None,
|
|
78
|
+
max_fee_per_gas: int | float | None = None,
|
|
79
|
+
max_priority_fee_per_gas: int | float | None = None,
|
|
80
|
+
min_confirmations: int = 1,
|
|
81
|
+
deadline_seconds: int | None = None,
|
|
82
|
+
*,
|
|
83
|
+
signer_address: str | None = None,
|
|
84
|
+
metadata: dict[str, Any] | None = None,
|
|
85
|
+
) -> TxIntentSpec:
|
|
86
|
+
"""Create a transaction intent specification.
|
|
87
|
+
|
|
88
|
+
Only valid inside build_intent().
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
to_address: Target contract address
|
|
92
|
+
data: Calldata (hex string)
|
|
93
|
+
value_wei: ETH value in wei (int or string)
|
|
94
|
+
gas_limit: Optional gas limit override
|
|
95
|
+
max_fee_per_gas: Optional EIP-1559 max fee
|
|
96
|
+
max_priority_fee_per_gas: Optional EIP-1559 priority fee
|
|
97
|
+
min_confirmations: Confirmations required (default 1)
|
|
98
|
+
deadline_seconds: Optional deadline for transaction
|
|
99
|
+
signer_address: Signer alias or hex address. If not provided, uses
|
|
100
|
+
the signer from @job(signer="...") decorator.
|
|
101
|
+
metadata: Per-intent context for alerts. Merged with trigger.reason
|
|
102
|
+
(job metadata wins on key collision).
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
TxIntentSpec instance
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
LookupError: If called outside build_intent()
|
|
109
|
+
RuntimeError: If no signer specified and job has no default signer
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
vault = Contract("vault")
|
|
113
|
+
return intent(
|
|
114
|
+
to_address=vault.address,
|
|
115
|
+
data=vault.harvest.encode_input(),
|
|
116
|
+
metadata={"profit": str(profit)},
|
|
117
|
+
)
|
|
118
|
+
"""
|
|
119
|
+
from brawny.model.types import TxIntentSpec
|
|
120
|
+
|
|
121
|
+
# Verify we're in a job context
|
|
122
|
+
get_job_context()
|
|
123
|
+
|
|
124
|
+
# Resolve signer: explicit param > job decorator > error
|
|
125
|
+
resolved_signer = signer_address
|
|
126
|
+
if resolved_signer is None:
|
|
127
|
+
job = get_current_job()
|
|
128
|
+
if job._signer_name is None:
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
f"No signer specified. Either pass signer_address= to intent() "
|
|
131
|
+
f"or set @job(signer='...') on {job.job_id}"
|
|
132
|
+
)
|
|
133
|
+
resolved_signer = job._signer_name
|
|
134
|
+
|
|
135
|
+
return TxIntentSpec(
|
|
136
|
+
signer_address=resolved_signer,
|
|
137
|
+
to_address=to_address,
|
|
138
|
+
data=data,
|
|
139
|
+
value_wei=str(int(value_wei)) if isinstance(value_wei, (int, float)) else value_wei,
|
|
140
|
+
gas_limit=int(gas_limit) if gas_limit is not None else None,
|
|
141
|
+
max_fee_per_gas=int(max_fee_per_gas) if max_fee_per_gas is not None else None,
|
|
142
|
+
max_priority_fee_per_gas=int(max_priority_fee_per_gas) if max_priority_fee_per_gas is not None else None,
|
|
143
|
+
min_confirmations=min_confirmations,
|
|
144
|
+
deadline_seconds=deadline_seconds,
|
|
145
|
+
metadata=metadata,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _BlockProxy:
|
|
150
|
+
"""Proxy object for accessing current block info.
|
|
151
|
+
|
|
152
|
+
Provides clean attribute access to block properties without needing
|
|
153
|
+
to pass context explicitly.
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
from brawny import block
|
|
157
|
+
|
|
158
|
+
if block.number % 100 == 0:
|
|
159
|
+
return trigger(reason="Periodic check")
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def number(self) -> int:
|
|
164
|
+
"""Current block number."""
|
|
165
|
+
return get_job_context().block.number
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def timestamp(self) -> int:
|
|
169
|
+
"""Current block timestamp (Unix seconds)."""
|
|
170
|
+
return get_job_context().block.timestamp
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def hash(self) -> str:
|
|
174
|
+
"""Current block hash (hex string with 0x prefix)."""
|
|
175
|
+
return get_job_context().block.hash
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Singleton instance for import
|
|
179
|
+
block = _BlockProxy()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def gas_ok() -> bool:
|
|
183
|
+
"""Check if current gas is acceptable.
|
|
184
|
+
|
|
185
|
+
Gate condition: 2 * base_fee + effective_priority_fee <= effective_max_fee
|
|
186
|
+
|
|
187
|
+
This matches what will actually be submitted, not RPC suggestions.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
True if gas acceptable or no gating configured (max_fee=None)
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
async def check(self):
|
|
194
|
+
if not await gas_ok():
|
|
195
|
+
return None
|
|
196
|
+
"""
|
|
197
|
+
from brawny._context import _current_job
|
|
198
|
+
from brawny.logging import get_logger
|
|
199
|
+
|
|
200
|
+
ctx = get_job_context()
|
|
201
|
+
job = _current_job.get()
|
|
202
|
+
|
|
203
|
+
# Get gas settings from job (no config fallback in OE7 contexts)
|
|
204
|
+
effective_max_fee = job.max_fee if job and job.max_fee is not None else None
|
|
205
|
+
effective_priority_fee = job.priority_fee if job and job.priority_fee is not None else 0
|
|
206
|
+
|
|
207
|
+
# No gating if max_fee is None
|
|
208
|
+
if effective_max_fee is None:
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
# Get quote (async)
|
|
212
|
+
quote = await ctx.rpc.gas_quote()
|
|
213
|
+
|
|
214
|
+
# Compute what we would actually submit
|
|
215
|
+
computed_max_fee = (2 * quote.base_fee) + effective_priority_fee
|
|
216
|
+
|
|
217
|
+
ok = computed_max_fee <= effective_max_fee
|
|
218
|
+
|
|
219
|
+
if not ok:
|
|
220
|
+
logger = get_logger(__name__)
|
|
221
|
+
logger.info(
|
|
222
|
+
"job.skipped_high_gas",
|
|
223
|
+
job_id=job.job_id if job else None,
|
|
224
|
+
base_fee=quote.base_fee,
|
|
225
|
+
effective_priority_fee=effective_priority_fee,
|
|
226
|
+
computed_max_fee=computed_max_fee,
|
|
227
|
+
effective_max_fee=effective_max_fee,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return ok
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def gas_quote() -> "GasQuote":
|
|
234
|
+
"""Get current gas quote.
|
|
235
|
+
|
|
236
|
+
Example:
|
|
237
|
+
quote = await gas_quote()
|
|
238
|
+
print(f"Base fee: {quote.base_fee / 1e9:.1f} gwei")
|
|
239
|
+
"""
|
|
240
|
+
from brawny._rpc.gas import GasQuote
|
|
241
|
+
|
|
242
|
+
return await get_job_context().rpc.gas_quote()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def ctx():
|
|
246
|
+
"""Get current phase context.
|
|
247
|
+
|
|
248
|
+
Use when you need the full context object in a job that uses
|
|
249
|
+
the implicit (no-parameter) signature.
|
|
250
|
+
|
|
251
|
+
Example:
|
|
252
|
+
from brawny import ctx, trigger
|
|
253
|
+
|
|
254
|
+
def check(self):
|
|
255
|
+
c = ctx()
|
|
256
|
+
c.logger.info("checking", block=c.block.number)
|
|
257
|
+
return trigger(reason="...")
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
CheckContext (in check()) or BuildContext (in build_tx())
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
LookupError: If called outside of job execution context.
|
|
264
|
+
"""
|
|
265
|
+
return get_job_context()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class _KVProxy:
|
|
269
|
+
"""Proxy object for accessing job's persistent KV store.
|
|
270
|
+
|
|
271
|
+
Works in job hooks (check/build_intent). Provides get/set/delete for
|
|
272
|
+
persistent key-value storage that survives restarts.
|
|
273
|
+
|
|
274
|
+
Usage:
|
|
275
|
+
from brawny import kv
|
|
276
|
+
|
|
277
|
+
def check(self):
|
|
278
|
+
last_price = kv.get("last_price")
|
|
279
|
+
kv.set("last_price", current_price)
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
283
|
+
"""Get value from KV store.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
key: Storage key
|
|
287
|
+
default: Default if not found
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Stored value or default
|
|
291
|
+
"""
|
|
292
|
+
return get_job_context().kv.get(key, default)
|
|
293
|
+
|
|
294
|
+
def set(self, key: str, value: Any) -> None:
|
|
295
|
+
"""Set value in KV store.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
key: Storage key
|
|
299
|
+
value: JSON-serializable value
|
|
300
|
+
"""
|
|
301
|
+
get_job_context().kv.set(key, value)
|
|
302
|
+
|
|
303
|
+
def delete(self, key: str) -> bool:
|
|
304
|
+
"""Delete key from KV store.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
key: Storage key
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
True if deleted, False if not found
|
|
311
|
+
"""
|
|
312
|
+
return get_job_context().kv.delete(key)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Singleton instance for import
|
|
316
|
+
kv = _KVProxy()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _get_rpc_from_context():
|
|
320
|
+
"""Get RPC manager from job context or alert context."""
|
|
321
|
+
from brawny._context import _job_ctx, get_alert_context
|
|
322
|
+
|
|
323
|
+
# Try job context first
|
|
324
|
+
job_ctx = _job_ctx.get()
|
|
325
|
+
if job_ctx is not None:
|
|
326
|
+
return job_ctx.rpc
|
|
327
|
+
|
|
328
|
+
# Try alert context (OE7 AlertContext has contracts: ContractFactory)
|
|
329
|
+
alert_ctx = get_alert_context()
|
|
330
|
+
if alert_ctx is not None and alert_ctx.contracts is not None:
|
|
331
|
+
return alert_ctx.contracts._system.rpc
|
|
332
|
+
|
|
333
|
+
raise LookupError(
|
|
334
|
+
"No active context. Must be called from within a job hook "
|
|
335
|
+
"(check/build_intent) or alert hook (alert_*)."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class _RPCProxy:
|
|
340
|
+
"""Proxy object for accessing the RPC manager.
|
|
341
|
+
|
|
342
|
+
Works in both job hooks (check/build_intent) and alert hooks.
|
|
343
|
+
Provides access to all RPCManager methods with failover and health tracking.
|
|
344
|
+
|
|
345
|
+
Usage:
|
|
346
|
+
from brawny import rpc
|
|
347
|
+
|
|
348
|
+
def check(self):
|
|
349
|
+
bal = rpc.get_balance(self.operator_address)
|
|
350
|
+
gas = rpc.get_gas_price()
|
|
351
|
+
|
|
352
|
+
def alert_failed(self, ctx):
|
|
353
|
+
bal = rpc.get_balance(self.operator_address) / 1e18
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def __getattr__(self, name: str):
|
|
357
|
+
"""Delegate attribute access to the underlying RPCManager."""
|
|
358
|
+
return getattr(_get_rpc_from_context(), name)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# Singleton instance for import
|
|
362
|
+
rpc = _RPCProxy()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# Thread-safe keystore address resolver - set once at startup
|
|
366
|
+
import threading
|
|
367
|
+
|
|
368
|
+
_keystore = None
|
|
369
|
+
_keystore_lock = threading.Lock()
|
|
370
|
+
_keystore_initialized = threading.Event()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _set_keystore(ks) -> None:
|
|
374
|
+
"""Called by framework at startup to make keystore available.
|
|
375
|
+
|
|
376
|
+
Thread-safe: uses lock and event to ensure visibility across threads.
|
|
377
|
+
Must be called before worker threads are started.
|
|
378
|
+
"""
|
|
379
|
+
global _keystore
|
|
380
|
+
with _keystore_lock:
|
|
381
|
+
_keystore = ks
|
|
382
|
+
_keystore_initialized.set()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _get_keystore():
|
|
386
|
+
"""Get keystore with thread-safe access.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
The keystore instance
|
|
390
|
+
|
|
391
|
+
Raises:
|
|
392
|
+
RuntimeError: If keystore not initialized within timeout
|
|
393
|
+
"""
|
|
394
|
+
# Fast path - already initialized
|
|
395
|
+
if _keystore is not None:
|
|
396
|
+
return _keystore
|
|
397
|
+
|
|
398
|
+
# Wait for initialization (with timeout)
|
|
399
|
+
if not _keystore_initialized.wait(timeout=5.0):
|
|
400
|
+
raise RuntimeError(
|
|
401
|
+
"Keystore not initialized within timeout. This is only available "
|
|
402
|
+
"when running with a keystore configured (not in dry-run mode)."
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
with _keystore_lock:
|
|
406
|
+
if _keystore is None:
|
|
407
|
+
raise RuntimeError(
|
|
408
|
+
"Keystore initialization signaled but keystore is None. "
|
|
409
|
+
"This should not happen - please report as a bug."
|
|
410
|
+
)
|
|
411
|
+
return _keystore
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def get_address_from_alias(alias: str) -> str:
|
|
415
|
+
"""Resolve a signer alias to its address.
|
|
416
|
+
|
|
417
|
+
Thread-safe: properly synchronized access to keystore.
|
|
418
|
+
|
|
419
|
+
Usage:
|
|
420
|
+
from brawny import get_address_from_alias
|
|
421
|
+
|
|
422
|
+
def alert_failed(self, ctx):
|
|
423
|
+
addr = get_address_from_alias("yearn-worker")
|
|
424
|
+
bal = rpc.get_balance(addr) / 1e18
|
|
425
|
+
"""
|
|
426
|
+
return _get_keystore().get_address(alias)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def shorten(hex_string: str, prefix: int = 6, suffix: int = 4) -> str:
|
|
430
|
+
"""Shorten a hex string (address or hash) for display.
|
|
431
|
+
|
|
432
|
+
Works in any context - no active hook required.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
hex_string: Full hex string (e.g., 0x1234...abcd)
|
|
436
|
+
prefix: Characters to keep at start (including 0x)
|
|
437
|
+
suffix: Characters to keep at end
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Shortened string like "0x1234...abcd"
|
|
441
|
+
|
|
442
|
+
Example:
|
|
443
|
+
from brawny import shorten
|
|
444
|
+
|
|
445
|
+
def alert_confirmed(self, ctx):
|
|
446
|
+
return f"Tx: {shorten(ctx.receipt.transactionHash.hex())}"
|
|
447
|
+
"""
|
|
448
|
+
from brawny.alerts.base import shorten as _shorten
|
|
449
|
+
|
|
450
|
+
return _shorten(hex_string, prefix, suffix)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def explorer_link(
|
|
454
|
+
hash_or_address: str,
|
|
455
|
+
chain_id: int | None = None,
|
|
456
|
+
label: str | None = None,
|
|
457
|
+
) -> str:
|
|
458
|
+
"""Create a Markdown explorer link with emoji.
|
|
459
|
+
|
|
460
|
+
Automatically detects chain_id from alert context if not provided.
|
|
461
|
+
Detects if input is a tx hash (66 chars) or address (42 chars).
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
hash_or_address: Transaction hash or address
|
|
465
|
+
chain_id: Chain ID (auto-detected from context if not provided)
|
|
466
|
+
label: Custom label (default: "🔗 View on Explorer")
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Markdown formatted link like "[🔗 View on Explorer](url)"
|
|
470
|
+
|
|
471
|
+
Example:
|
|
472
|
+
from brawny import explorer_link
|
|
473
|
+
|
|
474
|
+
def alert_confirmed(self, ctx):
|
|
475
|
+
return f"Done!\\n{explorer_link(ctx.receipt.transactionHash.hex())}"
|
|
476
|
+
"""
|
|
477
|
+
from brawny._context import get_alert_context
|
|
478
|
+
from brawny.alerts.base import explorer_link as _explorer_link
|
|
479
|
+
|
|
480
|
+
if chain_id is None:
|
|
481
|
+
alert_ctx = get_alert_context()
|
|
482
|
+
if alert_ctx is not None:
|
|
483
|
+
chain_id = alert_ctx.chain_id
|
|
484
|
+
else:
|
|
485
|
+
chain_id = 1 # Default to mainnet
|
|
486
|
+
|
|
487
|
+
return _explorer_link(hash_or_address, chain_id, label)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def Contract(address: str, abi: list[dict[str, Any]] | None = None) -> ContractHandle:
|
|
491
|
+
"""Get a contract handle (Brownie-style).
|
|
492
|
+
|
|
493
|
+
Works in job hooks (check/build_intent) and alert hooks.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
address: Ethereum address (0x...)
|
|
497
|
+
abi: Optional ABI override
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
ContractHandle bound to current context's RPC
|
|
501
|
+
|
|
502
|
+
Raises:
|
|
503
|
+
LookupError: If called outside a job or alert hook
|
|
504
|
+
|
|
505
|
+
Example:
|
|
506
|
+
vault = Contract(self.vault_address)
|
|
507
|
+
decimals = vault.decimals()
|
|
508
|
+
"""
|
|
509
|
+
from brawny._context import _job_ctx, get_alert_context, get_console_context
|
|
510
|
+
|
|
511
|
+
# Try job context first (CheckContext or BuildContext)
|
|
512
|
+
job_ctx = _job_ctx.get()
|
|
513
|
+
if job_ctx is not None:
|
|
514
|
+
if job_ctx.contracts is None:
|
|
515
|
+
raise RuntimeError(
|
|
516
|
+
"Contract system not configured. Ensure the contract system is initialized."
|
|
517
|
+
)
|
|
518
|
+
# Access the underlying ContractSystem via SimpleContractFactory._system
|
|
519
|
+
return job_ctx.contracts._system.handle(
|
|
520
|
+
address=address,
|
|
521
|
+
job_id=job_ctx.job_id,
|
|
522
|
+
abi=abi,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Try alert context (new OE7 AlertContext from model/contexts.py)
|
|
526
|
+
alert_ctx = get_alert_context()
|
|
527
|
+
if alert_ctx is not None:
|
|
528
|
+
if alert_ctx.contracts is None:
|
|
529
|
+
raise RuntimeError(
|
|
530
|
+
"Contract system not configured. Ensure the contract system is initialized."
|
|
531
|
+
)
|
|
532
|
+
# Access the underlying ContractSystem via SimpleContractFactory._system
|
|
533
|
+
return alert_ctx.contracts._system.handle(
|
|
534
|
+
address=address,
|
|
535
|
+
abi=abi,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Try console context
|
|
539
|
+
console_ctx = get_console_context()
|
|
540
|
+
if console_ctx is not None:
|
|
541
|
+
return console_ctx.contract_system.handle(address=address, abi=abi)
|
|
542
|
+
|
|
543
|
+
raise LookupError(
|
|
544
|
+
"No active context. Contract() must be called from within a job hook "
|
|
545
|
+
"(check/build_intent), alert hook (alert_*), or console."
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def Wei(value: str | int) -> int:
|
|
550
|
+
"""Convert to wei. Brownie-compatible.
|
|
551
|
+
|
|
552
|
+
Works anywhere - no active context required.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
value: Integer wei amount, or string like "1 ether", "100 gwei"
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
Amount in wei (int)
|
|
559
|
+
|
|
560
|
+
Example:
|
|
561
|
+
Wei("1 ether") → 1000000000000000000
|
|
562
|
+
Wei("100 gwei") → 100000000000
|
|
563
|
+
Wei("500 wei") → 500
|
|
564
|
+
Wei(123) → 123
|
|
565
|
+
"""
|
|
566
|
+
if isinstance(value, int):
|
|
567
|
+
return value
|
|
568
|
+
value_str = str(value).strip().lower()
|
|
569
|
+
if value_str.endswith(" ether"):
|
|
570
|
+
return int(float(value_str[:-6]) * 10**18)
|
|
571
|
+
elif value_str.endswith(" gwei"):
|
|
572
|
+
return int(float(value_str[:-5]) * 10**9)
|
|
573
|
+
elif value_str.endswith(" wei"):
|
|
574
|
+
return int(value_str[:-4])
|
|
575
|
+
return int(value_str)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class _Web3Proxy:
|
|
579
|
+
"""Proxy for context-aware web3 access.
|
|
580
|
+
|
|
581
|
+
Provides full web3-py API while using RPCManager's health-tracked endpoints.
|
|
582
|
+
Works in job hooks (check/build_intent) and alert hooks.
|
|
583
|
+
|
|
584
|
+
Usage:
|
|
585
|
+
from brawny import web3
|
|
586
|
+
|
|
587
|
+
balance = web3.eth.get_balance("0x...")
|
|
588
|
+
block = web3.eth.get_block("latest")
|
|
589
|
+
chain_id = web3.eth.chain_id
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
def __getattr__(self, name: str) -> Any:
|
|
593
|
+
"""Delegate attribute access to the underlying Web3 instance."""
|
|
594
|
+
return getattr(self._get_web3(), name)
|
|
595
|
+
|
|
596
|
+
def _get_web3(self):
|
|
597
|
+
"""Get Web3 instance from active context."""
|
|
598
|
+
from brawny._context import _job_ctx, get_alert_context, get_console_context
|
|
599
|
+
|
|
600
|
+
# Try job context first
|
|
601
|
+
job_ctx = _job_ctx.get()
|
|
602
|
+
if job_ctx is not None and job_ctx.rpc is not None:
|
|
603
|
+
return job_ctx.rpc.web3
|
|
604
|
+
|
|
605
|
+
# Try alert context (OE7 AlertContext has contracts: ContractFactory)
|
|
606
|
+
alert_ctx = get_alert_context()
|
|
607
|
+
if alert_ctx is not None and alert_ctx.contracts is not None:
|
|
608
|
+
return alert_ctx.contracts._system.rpc.web3
|
|
609
|
+
|
|
610
|
+
# Try console context
|
|
611
|
+
console_ctx = get_console_context()
|
|
612
|
+
if console_ctx is not None:
|
|
613
|
+
return console_ctx.rpc.web3
|
|
614
|
+
|
|
615
|
+
raise LookupError(
|
|
616
|
+
"No active context. web3 must be used from within a job hook "
|
|
617
|
+
"(check/build_intent), alert hook (alert_*), or console."
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def __repr__(self) -> str:
|
|
621
|
+
try:
|
|
622
|
+
w3 = self._get_web3()
|
|
623
|
+
return f"<Web3Proxy connected to chain {w3.eth.chain_id}>"
|
|
624
|
+
except LookupError:
|
|
625
|
+
return "<Web3Proxy (no active context)>"
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
# Singleton instance for import
|
|
629
|
+
web3 = _Web3Proxy()
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# Re-export Brownie-style singletons for convenience
|
|
633
|
+
from brawny.accounts import accounts, Account
|
|
634
|
+
from brawny.history import history
|
|
635
|
+
from brawny.chain import chain
|
|
636
|
+
|
|
637
|
+
# Re-export alert function for use in hooks
|
|
638
|
+
from brawny.alerts.send import alert
|
|
639
|
+
|
|
640
|
+
__all__ = [
|
|
641
|
+
"trigger",
|
|
642
|
+
"intent",
|
|
643
|
+
"alert",
|
|
644
|
+
"block",
|
|
645
|
+
"ctx",
|
|
646
|
+
"gas_ok",
|
|
647
|
+
"gas_quote",
|
|
648
|
+
"kv",
|
|
649
|
+
"rpc",
|
|
650
|
+
"Contract",
|
|
651
|
+
"Wei",
|
|
652
|
+
"web3",
|
|
653
|
+
"shorten",
|
|
654
|
+
"explorer_link",
|
|
655
|
+
"get_address_from_alias",
|
|
656
|
+
"accounts",
|
|
657
|
+
"Account",
|
|
658
|
+
"history",
|
|
659
|
+
"chain",
|
|
660
|
+
]
|