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,371 @@
|
|
|
1
|
+
"""Graceful shutdown handling for brawny.
|
|
2
|
+
|
|
3
|
+
Implements SPEC 9.5 Graceful Shutdown:
|
|
4
|
+
- Signal handling (SIGTERM, SIGINT)
|
|
5
|
+
- Graceful shutdown sequence
|
|
6
|
+
- In-progress intent handling
|
|
7
|
+
- Connection cleanup
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import signal
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import TYPE_CHECKING, Callable
|
|
18
|
+
|
|
19
|
+
from brawny.logging import LogEvents, get_logger
|
|
20
|
+
from brawny.model.enums import IntentStatus, NonceStatus
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from brawny.config import Config
|
|
24
|
+
from brawny.db.base import Database
|
|
25
|
+
from brawny._rpc.manager import RPCManager
|
|
26
|
+
from brawny.tx.nonce import NonceManager
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ShutdownState(str, Enum):
|
|
32
|
+
"""Shutdown state machine."""
|
|
33
|
+
|
|
34
|
+
RUNNING = "running"
|
|
35
|
+
SHUTTING_DOWN = "shutting_down"
|
|
36
|
+
SHUTDOWN_COMPLETE = "shutdown_complete"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ShutdownStats:
|
|
41
|
+
"""Statistics from shutdown process."""
|
|
42
|
+
|
|
43
|
+
claimed_released: int = 0
|
|
44
|
+
pending_orphaned: int = 0
|
|
45
|
+
errors: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ShutdownHandler:
|
|
49
|
+
"""Manages graceful shutdown of brawny.
|
|
50
|
+
|
|
51
|
+
Handles SIGTERM and SIGINT signals to trigger graceful shutdown.
|
|
52
|
+
Coordinates shutdown of all components in proper order.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
config: Config,
|
|
58
|
+
db: Database | None = None,
|
|
59
|
+
rpc: RPCManager | None = None,
|
|
60
|
+
nonce_manager: NonceManager | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Initialize shutdown handler.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
config: Application configuration
|
|
66
|
+
db: Database connection (optional, set later)
|
|
67
|
+
rpc: RPC manager (optional, set later)
|
|
68
|
+
nonce_manager: Nonce manager (optional, set later)
|
|
69
|
+
"""
|
|
70
|
+
self._config = config
|
|
71
|
+
self._db = db
|
|
72
|
+
self._rpc = rpc
|
|
73
|
+
self._nonce_manager = nonce_manager
|
|
74
|
+
|
|
75
|
+
self._state = ShutdownState.RUNNING
|
|
76
|
+
self._shutdown_flag = threading.Event()
|
|
77
|
+
self._shutdown_lock = threading.Lock()
|
|
78
|
+
|
|
79
|
+
# Callbacks to notify on shutdown
|
|
80
|
+
self._shutdown_callbacks: list[Callable[[], None]] = []
|
|
81
|
+
self._callbacks_notified = False
|
|
82
|
+
|
|
83
|
+
# Force exit counter
|
|
84
|
+
self._signal_count = 0
|
|
85
|
+
|
|
86
|
+
# Stats
|
|
87
|
+
self._stats = ShutdownStats()
|
|
88
|
+
|
|
89
|
+
def set_db(self, db: Database) -> None:
|
|
90
|
+
"""Set database connection."""
|
|
91
|
+
self._db = db
|
|
92
|
+
|
|
93
|
+
def set_rpc(self, rpc: RPCManager) -> None:
|
|
94
|
+
"""Set RPC manager."""
|
|
95
|
+
self._rpc = rpc
|
|
96
|
+
|
|
97
|
+
def set_nonce_manager(self, nonce_manager: NonceManager) -> None:
|
|
98
|
+
"""Set nonce manager."""
|
|
99
|
+
self._nonce_manager = nonce_manager
|
|
100
|
+
|
|
101
|
+
def register_callback(self, callback: Callable[[], None]) -> None:
|
|
102
|
+
"""Register a callback to be called on shutdown.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
callback: Function to call during shutdown
|
|
106
|
+
"""
|
|
107
|
+
self._shutdown_callbacks.append(callback)
|
|
108
|
+
|
|
109
|
+
def install_signal_handlers(self) -> None:
|
|
110
|
+
"""Install signal handlers for graceful shutdown."""
|
|
111
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
112
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
113
|
+
logger.debug("shutdown.handlers_installed")
|
|
114
|
+
|
|
115
|
+
def _signal_handler(self, signum: int, frame) -> None:
|
|
116
|
+
"""Handle shutdown signal.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
signum: Signal number
|
|
120
|
+
frame: Current stack frame
|
|
121
|
+
"""
|
|
122
|
+
import os
|
|
123
|
+
import sys
|
|
124
|
+
|
|
125
|
+
self._signal_count += 1
|
|
126
|
+
signal_name = signal.Signals(signum).name
|
|
127
|
+
|
|
128
|
+
if self._signal_count >= 3:
|
|
129
|
+
logger.warning("shutdown.force_exit", signal_count=self._signal_count)
|
|
130
|
+
print("\nForce exit.", file=sys.stderr)
|
|
131
|
+
os._exit(1)
|
|
132
|
+
|
|
133
|
+
logger.info(
|
|
134
|
+
"shutdown.signal_received",
|
|
135
|
+
signal=signal_name,
|
|
136
|
+
count=self._signal_count,
|
|
137
|
+
)
|
|
138
|
+
self.initiate_shutdown()
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def is_shutting_down(self) -> bool:
|
|
142
|
+
"""Check if shutdown has been initiated."""
|
|
143
|
+
return self._state != ShutdownState.RUNNING
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def shutdown_flag(self) -> threading.Event:
|
|
147
|
+
"""Get shutdown flag for waiting."""
|
|
148
|
+
return self._shutdown_flag
|
|
149
|
+
|
|
150
|
+
def initiate_shutdown(self) -> None:
|
|
151
|
+
"""Initiate graceful shutdown.
|
|
152
|
+
|
|
153
|
+
Can be called from signal handler or programmatically.
|
|
154
|
+
Immediately notifies callbacks to stop blocking loops.
|
|
155
|
+
"""
|
|
156
|
+
with self._shutdown_lock:
|
|
157
|
+
if self._state != ShutdownState.RUNNING:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
self._state = ShutdownState.SHUTTING_DOWN
|
|
161
|
+
self._shutdown_flag.set()
|
|
162
|
+
|
|
163
|
+
logger.info(LogEvents.SHUTDOWN_INITIATED)
|
|
164
|
+
|
|
165
|
+
# Notify callbacks immediately (outside lock to avoid deadlock)
|
|
166
|
+
# This stops blocking loops like the poller
|
|
167
|
+
self._notify_callbacks()
|
|
168
|
+
|
|
169
|
+
def wait_for_shutdown(self, timeout: float | None = None) -> bool:
|
|
170
|
+
"""Wait for shutdown signal.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
timeout: Maximum time to wait in seconds
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if shutdown was signaled, False if timeout
|
|
177
|
+
"""
|
|
178
|
+
return self._shutdown_flag.wait(timeout)
|
|
179
|
+
|
|
180
|
+
def execute_shutdown(self, timeout: float | None = None) -> ShutdownStats:
|
|
181
|
+
"""Execute the full shutdown sequence.
|
|
182
|
+
|
|
183
|
+
Per SPEC 9.5:
|
|
184
|
+
1. Stop accepting new block processing
|
|
185
|
+
2. Wait for current block to finish
|
|
186
|
+
3. Handle in-progress intents
|
|
187
|
+
4. Flush logs and metrics
|
|
188
|
+
5. Close connections
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
timeout: Maximum time for shutdown (default from config)
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Shutdown statistics
|
|
195
|
+
"""
|
|
196
|
+
timeout = timeout or self._config.shutdown_timeout_seconds
|
|
197
|
+
|
|
198
|
+
logger.info(
|
|
199
|
+
"shutdown.executing",
|
|
200
|
+
timeout_seconds=timeout,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
start_time = time.time()
|
|
204
|
+
self._stats = ShutdownStats()
|
|
205
|
+
|
|
206
|
+
# 1. Notify registered callbacks (stops pollers, workers, etc.)
|
|
207
|
+
self._notify_callbacks()
|
|
208
|
+
|
|
209
|
+
# 2. Wait for graceful completion (callbacks should signal)
|
|
210
|
+
remaining = timeout - (time.time() - start_time)
|
|
211
|
+
if remaining > 0:
|
|
212
|
+
time.sleep(min(remaining, 2.0)) # Brief wait for work to finish
|
|
213
|
+
|
|
214
|
+
# 3. Handle in-progress intents
|
|
215
|
+
self._handle_in_progress_intents()
|
|
216
|
+
|
|
217
|
+
# 4. Close RPC connections
|
|
218
|
+
if self._rpc:
|
|
219
|
+
try:
|
|
220
|
+
self._rpc.close()
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.warning("shutdown.rpc_close_failed", error=str(e)[:200])
|
|
223
|
+
|
|
224
|
+
# 5. Close database
|
|
225
|
+
if self._db:
|
|
226
|
+
try:
|
|
227
|
+
self._db.close()
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.warning("shutdown.db_close_failed", error=str(e)[:200])
|
|
230
|
+
|
|
231
|
+
# Mark shutdown complete
|
|
232
|
+
self._state = ShutdownState.SHUTDOWN_COMPLETE
|
|
233
|
+
|
|
234
|
+
elapsed = time.time() - start_time
|
|
235
|
+
logger.info(
|
|
236
|
+
LogEvents.SHUTDOWN_COMPLETE,
|
|
237
|
+
elapsed_seconds=round(elapsed, 2),
|
|
238
|
+
claimed_released=self._stats.claimed_released,
|
|
239
|
+
pending_orphaned=self._stats.pending_orphaned,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return self._stats
|
|
243
|
+
|
|
244
|
+
def _notify_callbacks(self) -> None:
|
|
245
|
+
"""Notify all registered shutdown callbacks.
|
|
246
|
+
|
|
247
|
+
Only runs once, even if called multiple times.
|
|
248
|
+
"""
|
|
249
|
+
if self._callbacks_notified:
|
|
250
|
+
return
|
|
251
|
+
self._callbacks_notified = True
|
|
252
|
+
|
|
253
|
+
logger.info("shutdown.notifying_callbacks", count=len(self._shutdown_callbacks))
|
|
254
|
+
|
|
255
|
+
for i, callback in enumerate(self._shutdown_callbacks):
|
|
256
|
+
try:
|
|
257
|
+
logger.debug("shutdown.callback_start", index=i)
|
|
258
|
+
callback()
|
|
259
|
+
logger.debug("shutdown.callback_done", index=i)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.warning(
|
|
262
|
+
"shutdown.callback_failed",
|
|
263
|
+
index=i,
|
|
264
|
+
error=str(e)[:200],
|
|
265
|
+
)
|
|
266
|
+
self._stats.errors += 1
|
|
267
|
+
|
|
268
|
+
logger.info("shutdown.callbacks_complete")
|
|
269
|
+
|
|
270
|
+
def _handle_in_progress_intents(self) -> None:
|
|
271
|
+
"""Handle intents that are in progress during shutdown.
|
|
272
|
+
|
|
273
|
+
Per SPEC 9.5:
|
|
274
|
+
- Claimed but not broadcast: release back to queue
|
|
275
|
+
- Sending/pending: leave for reconciliation on restart
|
|
276
|
+
"""
|
|
277
|
+
if not self._db:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
# Handle claimed intents (not yet broadcast)
|
|
282
|
+
claimed = self._db.get_intents_by_status(
|
|
283
|
+
IntentStatus.CLAIMED.value,
|
|
284
|
+
chain_id=self._config.chain_id,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
for intent in claimed:
|
|
288
|
+
try:
|
|
289
|
+
# Release claim (revert to created)
|
|
290
|
+
self._db.release_intent_claim(intent.intent_id)
|
|
291
|
+
|
|
292
|
+
# Get the attempt to find nonce
|
|
293
|
+
attempt = self._db.get_latest_attempt_for_intent(intent.intent_id)
|
|
294
|
+
if attempt and self._nonce_manager:
|
|
295
|
+
# Release nonce reservation
|
|
296
|
+
self._nonce_manager.release(
|
|
297
|
+
intent.signer_address,
|
|
298
|
+
attempt.nonce,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
logger.debug(
|
|
302
|
+
"shutdown.intent_released",
|
|
303
|
+
intent_id=str(intent.intent_id),
|
|
304
|
+
)
|
|
305
|
+
self._stats.claimed_released += 1
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.warning(
|
|
309
|
+
"shutdown.intent_release_failed",
|
|
310
|
+
intent_id=str(intent.intent_id),
|
|
311
|
+
error=str(e)[:200],
|
|
312
|
+
)
|
|
313
|
+
self._stats.errors += 1
|
|
314
|
+
|
|
315
|
+
# Log pending/sending intents (left for reconciliation)
|
|
316
|
+
in_flight_statuses = [
|
|
317
|
+
IntentStatus.SENDING.value,
|
|
318
|
+
IntentStatus.PENDING.value,
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
for status in in_flight_statuses:
|
|
322
|
+
intents = self._db.get_intents_by_status(
|
|
323
|
+
status,
|
|
324
|
+
chain_id=self._config.chain_id,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
for intent in intents:
|
|
328
|
+
logger.info(
|
|
329
|
+
"shutdown.orphan_pending",
|
|
330
|
+
intent_id=str(intent.intent_id),
|
|
331
|
+
status=status,
|
|
332
|
+
)
|
|
333
|
+
self._stats.pending_orphaned += 1
|
|
334
|
+
|
|
335
|
+
except Exception as e:
|
|
336
|
+
logger.error(
|
|
337
|
+
"shutdown.handle_intents_failed",
|
|
338
|
+
error=str(e)[:200],
|
|
339
|
+
)
|
|
340
|
+
self._stats.errors += 1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class ShutdownContext:
|
|
344
|
+
"""Context manager for graceful shutdown handling.
|
|
345
|
+
|
|
346
|
+
Usage:
|
|
347
|
+
handler = ShutdownHandler(config)
|
|
348
|
+
with ShutdownContext(handler):
|
|
349
|
+
# Main application loop
|
|
350
|
+
while not handler.is_shutting_down:
|
|
351
|
+
process_block()
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
def __init__(self, handler: ShutdownHandler) -> None:
|
|
355
|
+
"""Initialize context.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
handler: Shutdown handler to use
|
|
359
|
+
"""
|
|
360
|
+
self._handler = handler
|
|
361
|
+
|
|
362
|
+
def __enter__(self) -> ShutdownHandler:
|
|
363
|
+
"""Enter context and install signal handlers."""
|
|
364
|
+
self._handler.install_signal_handlers()
|
|
365
|
+
return self._handler
|
|
366
|
+
|
|
367
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
368
|
+
"""Exit context and execute shutdown."""
|
|
369
|
+
if self._handler.is_shutting_down:
|
|
370
|
+
self._handler.execute_shutdown()
|
|
371
|
+
return False # Don't suppress exceptions
|
brawny/script_tx.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Transaction broadcasting for standalone scripts.
|
|
2
|
+
|
|
3
|
+
Simpler flow than job-based execution:
|
|
4
|
+
- No intent persistence
|
|
5
|
+
- No replacement/monitoring
|
|
6
|
+
- Direct broadcast and wait
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from brawny.tx.utils import normalize_tx_dict
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from brawny.keystore import Keystore
|
|
18
|
+
from brawny._rpc.manager import RPCManager
|
|
19
|
+
from brawny.jobs.base import TxReceipt
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransactionBroadcaster:
|
|
23
|
+
"""Broadcasts transactions for script context.
|
|
24
|
+
|
|
25
|
+
Uses brawny RPC infrastructure (retry, failover) but
|
|
26
|
+
simpler execution flow than job-based TxExecutor.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
rpc: "RPCManager",
|
|
32
|
+
keystore: "Keystore",
|
|
33
|
+
chain_id: int,
|
|
34
|
+
timeout_seconds: int = 120,
|
|
35
|
+
poll_interval: float = 1.0,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._rpc = rpc
|
|
38
|
+
self._keystore = keystore
|
|
39
|
+
self._chain_id = chain_id
|
|
40
|
+
self._timeout = timeout_seconds
|
|
41
|
+
self._poll_interval = poll_interval
|
|
42
|
+
|
|
43
|
+
def transfer(
|
|
44
|
+
self,
|
|
45
|
+
sender: str,
|
|
46
|
+
to: str,
|
|
47
|
+
value: int,
|
|
48
|
+
gas_limit: int | None = None,
|
|
49
|
+
gas_price: int | None = None,
|
|
50
|
+
max_fee_per_gas: int | None = None,
|
|
51
|
+
max_priority_fee_per_gas: int | None = None,
|
|
52
|
+
data: str | None = None,
|
|
53
|
+
nonce: int | None = None,
|
|
54
|
+
private_key: bytes | None = None,
|
|
55
|
+
) -> "TxReceipt":
|
|
56
|
+
"""Send a transfer transaction.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
sender: Sender address
|
|
60
|
+
to: Recipient address
|
|
61
|
+
value: Amount in wei
|
|
62
|
+
gas_limit: Optional gas limit
|
|
63
|
+
gas_price: Optional legacy gas price
|
|
64
|
+
max_fee_per_gas: Optional EIP-1559 max fee
|
|
65
|
+
max_priority_fee_per_gas: Optional EIP-1559 priority fee
|
|
66
|
+
data: Optional calldata
|
|
67
|
+
nonce: Optional nonce override
|
|
68
|
+
private_key: Optional private key for signing (from Account).
|
|
69
|
+
If None, uses the keystore to sign.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Transaction receipt on confirmation
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
TransactionRevertedError: If transaction reverts
|
|
76
|
+
TransactionTimeoutError: If confirmation times out
|
|
77
|
+
"""
|
|
78
|
+
from brawny.history import _add_to_history
|
|
79
|
+
|
|
80
|
+
# Build transaction
|
|
81
|
+
tx = self._build_transaction(
|
|
82
|
+
sender=sender,
|
|
83
|
+
to=to,
|
|
84
|
+
value=value,
|
|
85
|
+
data=data or "0x",
|
|
86
|
+
gas_limit=gas_limit,
|
|
87
|
+
gas_price=gas_price,
|
|
88
|
+
max_fee_per_gas=max_fee_per_gas,
|
|
89
|
+
max_priority_fee_per_gas=max_priority_fee_per_gas,
|
|
90
|
+
nonce=nonce,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Sign and broadcast
|
|
94
|
+
receipt = self._sign_and_broadcast(tx, sender, private_key)
|
|
95
|
+
|
|
96
|
+
# Add to history
|
|
97
|
+
_add_to_history(receipt)
|
|
98
|
+
|
|
99
|
+
return receipt
|
|
100
|
+
|
|
101
|
+
def transact(
|
|
102
|
+
self,
|
|
103
|
+
sender: str,
|
|
104
|
+
to: str,
|
|
105
|
+
data: str,
|
|
106
|
+
value: int = 0,
|
|
107
|
+
gas_limit: int | None = None,
|
|
108
|
+
gas_price: int | None = None,
|
|
109
|
+
max_fee_per_gas: int | None = None,
|
|
110
|
+
max_priority_fee_per_gas: int | None = None,
|
|
111
|
+
nonce: int | None = None,
|
|
112
|
+
private_key: bytes | None = None,
|
|
113
|
+
) -> "TxReceipt":
|
|
114
|
+
"""Send a contract transaction.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
sender: Sender address
|
|
118
|
+
to: Contract address
|
|
119
|
+
data: Encoded calldata
|
|
120
|
+
value: Optional ETH value in wei
|
|
121
|
+
gas_limit: Optional gas limit
|
|
122
|
+
gas_price: Optional legacy gas price
|
|
123
|
+
max_fee_per_gas: Optional EIP-1559 max fee
|
|
124
|
+
max_priority_fee_per_gas: Optional EIP-1559 priority fee
|
|
125
|
+
nonce: Optional nonce override
|
|
126
|
+
private_key: Optional private key for signing (from Account).
|
|
127
|
+
If None, uses the keystore to sign.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Transaction receipt on confirmation
|
|
131
|
+
"""
|
|
132
|
+
from brawny.history import _add_to_history
|
|
133
|
+
|
|
134
|
+
tx = self._build_transaction(
|
|
135
|
+
sender=sender,
|
|
136
|
+
to=to,
|
|
137
|
+
value=value,
|
|
138
|
+
data=data,
|
|
139
|
+
gas_limit=gas_limit,
|
|
140
|
+
gas_price=gas_price,
|
|
141
|
+
max_fee_per_gas=max_fee_per_gas,
|
|
142
|
+
max_priority_fee_per_gas=max_priority_fee_per_gas,
|
|
143
|
+
nonce=nonce,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
receipt = self._sign_and_broadcast(tx, sender, private_key)
|
|
147
|
+
_add_to_history(receipt)
|
|
148
|
+
|
|
149
|
+
return receipt
|
|
150
|
+
|
|
151
|
+
def _build_transaction(
|
|
152
|
+
self,
|
|
153
|
+
sender: str,
|
|
154
|
+
to: str,
|
|
155
|
+
value: int,
|
|
156
|
+
data: str,
|
|
157
|
+
gas_limit: int | None,
|
|
158
|
+
gas_price: int | None,
|
|
159
|
+
max_fee_per_gas: int | None,
|
|
160
|
+
max_priority_fee_per_gas: int | None,
|
|
161
|
+
nonce: int | None,
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
"""Build transaction dictionary."""
|
|
164
|
+
tx: dict[str, Any] = {
|
|
165
|
+
"from": sender,
|
|
166
|
+
"to": to,
|
|
167
|
+
"value": value,
|
|
168
|
+
"data": data,
|
|
169
|
+
"chainId": self._chain_id,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Nonce
|
|
173
|
+
if nonce is None:
|
|
174
|
+
nonce = self._rpc.get_transaction_count(sender, "pending")
|
|
175
|
+
tx["nonce"] = nonce
|
|
176
|
+
|
|
177
|
+
# Gas price (EIP-1559 or legacy)
|
|
178
|
+
if max_fee_per_gas is not None:
|
|
179
|
+
tx["maxFeePerGas"] = max_fee_per_gas
|
|
180
|
+
tx["maxPriorityFeePerGas"] = max_priority_fee_per_gas or 0
|
|
181
|
+
tx["type"] = 2
|
|
182
|
+
elif gas_price is not None:
|
|
183
|
+
tx["gasPrice"] = gas_price
|
|
184
|
+
else:
|
|
185
|
+
# Auto gas price
|
|
186
|
+
try:
|
|
187
|
+
base_fee = self._rpc.get_block("latest").get("baseFeePerGas")
|
|
188
|
+
if base_fee:
|
|
189
|
+
# EIP-1559
|
|
190
|
+
priority = self._rpc.call("eth_maxPriorityFeePerGas")
|
|
191
|
+
tx["maxFeePerGas"] = base_fee * 2 + int(priority, 16)
|
|
192
|
+
tx["maxPriorityFeePerGas"] = int(priority, 16)
|
|
193
|
+
tx["type"] = 2
|
|
194
|
+
else:
|
|
195
|
+
tx["gasPrice"] = self._rpc.get_gas_price()
|
|
196
|
+
except Exception:
|
|
197
|
+
tx["gasPrice"] = self._rpc.get_gas_price()
|
|
198
|
+
|
|
199
|
+
# Gas limit
|
|
200
|
+
if gas_limit is None:
|
|
201
|
+
estimate_tx = {k: v for k, v in tx.items() if k != "nonce"}
|
|
202
|
+
gas_limit = self._rpc.estimate_gas(estimate_tx)
|
|
203
|
+
gas_limit = gas_limit * 1.1 # 10% buffer
|
|
204
|
+
tx["gas"] = gas_limit
|
|
205
|
+
|
|
206
|
+
return normalize_tx_dict(tx)
|
|
207
|
+
|
|
208
|
+
def _sign_and_broadcast(
|
|
209
|
+
self,
|
|
210
|
+
tx: dict[str, Any],
|
|
211
|
+
sender: str,
|
|
212
|
+
private_key: bytes | None = None,
|
|
213
|
+
) -> "TxReceipt":
|
|
214
|
+
"""Sign transaction and broadcast, waiting for receipt.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
tx: Transaction dictionary
|
|
218
|
+
sender: Sender address
|
|
219
|
+
private_key: Optional private key for direct signing (from Account).
|
|
220
|
+
If None, uses the keystore to sign.
|
|
221
|
+
"""
|
|
222
|
+
from brawny.jobs.base import TxReceipt
|
|
223
|
+
from brawny.scripting import TransactionRevertedError, TransactionTimeoutError
|
|
224
|
+
from eth_account import Account as EthAccount
|
|
225
|
+
|
|
226
|
+
# Sign - either with private key or keystore
|
|
227
|
+
if private_key is not None:
|
|
228
|
+
signed = EthAccount.sign_transaction(tx, private_key)
|
|
229
|
+
else:
|
|
230
|
+
signed = self._keystore.sign_transaction(tx, sender)
|
|
231
|
+
|
|
232
|
+
# Broadcast (handle both old and new eth-account attribute names)
|
|
233
|
+
raw_tx = getattr(signed, "raw_transaction", None) or signed.rawTransaction
|
|
234
|
+
tx_hash = self._rpc.send_raw_transaction(raw_tx)
|
|
235
|
+
|
|
236
|
+
# Wait for receipt
|
|
237
|
+
start = time.time()
|
|
238
|
+
while True:
|
|
239
|
+
receipt_data = self._rpc.get_transaction_receipt(tx_hash)
|
|
240
|
+
if receipt_data is not None:
|
|
241
|
+
# Handle status as hex string or int
|
|
242
|
+
status = receipt_data["status"]
|
|
243
|
+
if isinstance(status, str):
|
|
244
|
+
status = int(status, 16)
|
|
245
|
+
|
|
246
|
+
# Handle block_number as hex string or int
|
|
247
|
+
block_number = receipt_data["blockNumber"]
|
|
248
|
+
if isinstance(block_number, str):
|
|
249
|
+
block_number = int(block_number, 16)
|
|
250
|
+
|
|
251
|
+
# Handle gas_used as hex string or int
|
|
252
|
+
gas_used = receipt_data["gasUsed"]
|
|
253
|
+
if isinstance(gas_used, str):
|
|
254
|
+
gas_used = int(gas_used, 16)
|
|
255
|
+
|
|
256
|
+
receipt = TxReceipt(
|
|
257
|
+
transaction_hash=receipt_data["transactionHash"],
|
|
258
|
+
block_number=block_number,
|
|
259
|
+
block_hash=receipt_data["blockHash"],
|
|
260
|
+
status=status,
|
|
261
|
+
gas_used=gas_used,
|
|
262
|
+
logs=receipt_data.get("logs", []),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if receipt.status == 0:
|
|
266
|
+
raise TransactionRevertedError(tx_hash)
|
|
267
|
+
|
|
268
|
+
return receipt
|
|
269
|
+
|
|
270
|
+
if time.time() - start > self._timeout:
|
|
271
|
+
raise TransactionTimeoutError(tx_hash, self._timeout)
|
|
272
|
+
|
|
273
|
+
time.sleep(self._poll_interval)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# Global broadcaster instance
|
|
277
|
+
_broadcaster: TransactionBroadcaster | None = None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _init_broadcaster(
|
|
281
|
+
rpc: "RPCManager",
|
|
282
|
+
keystore: "Keystore",
|
|
283
|
+
chain_id: int,
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Initialize global broadcaster."""
|
|
286
|
+
global _broadcaster
|
|
287
|
+
_broadcaster = TransactionBroadcaster(rpc, keystore, chain_id)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _get_broadcaster() -> TransactionBroadcaster:
|
|
291
|
+
"""Get broadcaster singleton."""
|
|
292
|
+
if _broadcaster is None:
|
|
293
|
+
raise RuntimeError(
|
|
294
|
+
"Transaction broadcaster not initialized. "
|
|
295
|
+
"Run within script context."
|
|
296
|
+
)
|
|
297
|
+
return _broadcaster
|