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