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
brawny/tx/monitor.py ADDED
@@ -0,0 +1,628 @@
1
+ """Transaction confirmation monitoring.
2
+
3
+ Implements the confirmation monitoring loop from SPEC 9.3:
4
+ - Poll for transaction receipt
5
+ - Verify receipt is on canonical chain
6
+ - Count confirmations
7
+ - Detect dropped/stuck transactions
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from datetime import datetime, timezone
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from typing import TYPE_CHECKING, Any
17
+ from uuid import UUID
18
+
19
+ from web3 import Web3
20
+
21
+ from brawny.logging import LogEvents, get_logger
22
+ from brawny.metrics import (
23
+ TX_CONFIRMED,
24
+ TX_FAILED,
25
+ TX_CONFIRMATION_SECONDS,
26
+ LAST_TX_CONFIRMED_TIMESTAMP,
27
+ LAST_INTENT_COMPLETED_TIMESTAMP,
28
+ get_metrics,
29
+ )
30
+ from brawny.model.enums import AttemptStatus, IntentStatus, NonceStatus
31
+ from brawny.model.errors import DatabaseError, FailureType, FailureStage
32
+ from brawny._rpc.errors import RPCError
33
+ from brawny.tx.intent import transition_intent
34
+
35
+ if TYPE_CHECKING:
36
+ from brawny.config import Config
37
+ from brawny.db.base import Database
38
+ from brawny.lifecycle import LifecycleDispatcher
39
+ from brawny.model.types import TxAttempt, TxIntent
40
+ from brawny._rpc.manager import RPCManager
41
+ from brawny.tx.nonce import NonceManager
42
+
43
+ logger = get_logger(__name__)
44
+
45
+
46
+ class ConfirmationResult(str, Enum):
47
+ """Result of confirmation monitoring."""
48
+
49
+ CONFIRMED = "confirmed"
50
+ REVERTED = "reverted"
51
+ DROPPED = "dropped"
52
+ STUCK = "stuck"
53
+ PENDING = "pending"
54
+
55
+
56
+ @dataclass
57
+ class ConfirmationStatus:
58
+ """Status returned from confirmation check."""
59
+
60
+ result: ConfirmationResult
61
+ confirmations: int = 0
62
+ block_number: int | None = None
63
+ block_hash: str | None = None
64
+ gas_used: int | None = None
65
+ receipt: dict[str, Any] | None = None
66
+
67
+
68
+ class TxMonitor:
69
+ """Monitor transactions for confirmation.
70
+
71
+ Implements SPEC 9.3 confirmation monitoring with:
72
+ - Receipt polling with configurable interval
73
+ - Canonical chain verification
74
+ - Confirmation counting
75
+ - Dropped/stuck transaction detection
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ db: Database,
81
+ rpc: RPCManager,
82
+ nonce_manager: NonceManager,
83
+ config: Config,
84
+ lifecycle: "LifecycleDispatcher | None" = None,
85
+ ) -> None:
86
+ """Initialize transaction monitor.
87
+
88
+ Args:
89
+ db: Database connection
90
+ rpc: RPC manager for chain queries
91
+ nonce_manager: Nonce manager for releasing reservations
92
+ config: Application configuration
93
+ """
94
+ self._db = db
95
+ self._rpc = rpc
96
+ self._nonce_manager = nonce_manager
97
+ self._config = config
98
+ self._lifecycle = lifecycle
99
+
100
+ def check_confirmation(
101
+ self,
102
+ intent: TxIntent,
103
+ attempt: TxAttempt,
104
+ ) -> ConfirmationStatus:
105
+ """Check confirmation status for a transaction attempt.
106
+
107
+ This is a non-blocking check that returns the current status.
108
+ For continuous monitoring, call this repeatedly.
109
+
110
+ Args:
111
+ intent: Transaction intent
112
+ attempt: Transaction attempt with tx_hash
113
+
114
+ Returns:
115
+ Current confirmation status
116
+ """
117
+ if not attempt.tx_hash:
118
+ logger.warning(
119
+ "monitor.no_tx_hash",
120
+ intent_id=str(intent.intent_id),
121
+ attempt_id=str(attempt.attempt_id),
122
+ )
123
+ return ConfirmationStatus(result=ConfirmationResult.PENDING)
124
+
125
+ # Get receipt
126
+ receipt = self._rpc.get_transaction_receipt(attempt.tx_hash)
127
+
128
+ if receipt is None:
129
+ # No receipt yet - check if nonce has been consumed by another tx
130
+ if self._is_nonce_consumed(intent, attempt):
131
+ metrics = get_metrics()
132
+ metrics.counter(TX_FAILED).inc(
133
+ chain_id=intent.chain_id,
134
+ job_id=intent.job_id,
135
+ reason="dropped",
136
+ )
137
+ return ConfirmationStatus(result=ConfirmationResult.DROPPED)
138
+
139
+ # Check if stuck
140
+ if self._is_stuck(attempt):
141
+ metrics = get_metrics()
142
+ metrics.counter(TX_FAILED).inc(
143
+ chain_id=intent.chain_id,
144
+ job_id=intent.job_id,
145
+ reason="stuck",
146
+ )
147
+ return ConfirmationStatus(result=ConfirmationResult.STUCK)
148
+
149
+ return ConfirmationStatus(result=ConfirmationResult.PENDING)
150
+
151
+ # Have receipt - verify it's on canonical chain
152
+ receipt_block_number = receipt.get("blockNumber")
153
+ receipt_block_hash = receipt.get("blockHash")
154
+
155
+ if receipt_block_hash:
156
+ # Convert HexBytes to str if needed
157
+ if hasattr(receipt_block_hash, "hex"):
158
+ receipt_block_hash = receipt_block_hash.hex()
159
+ if not receipt_block_hash.startswith("0x"):
160
+ receipt_block_hash = f"0x{receipt_block_hash}"
161
+
162
+ # Verify block hash matches current chain
163
+ try:
164
+ current_block = self._rpc.get_block(receipt_block_number)
165
+ current_hash = current_block.get("hash")
166
+ if hasattr(current_hash, "hex"):
167
+ current_hash = current_hash.hex()
168
+ if current_hash and not current_hash.startswith("0x"):
169
+ current_hash = f"0x{current_hash}"
170
+
171
+ if current_hash != receipt_block_hash:
172
+ # Receipt is from reorged block
173
+ logger.info(
174
+ "tx.reorg_pending",
175
+ tx_hash=attempt.tx_hash,
176
+ receipt_block=receipt_block_number,
177
+ receipt_hash=receipt_block_hash[:18] if receipt_block_hash else None,
178
+ current_hash=current_hash[:18] if current_hash else None,
179
+ )
180
+ return ConfirmationStatus(result=ConfirmationResult.PENDING)
181
+ except Exception as e:
182
+ logger.warning(
183
+ "monitor.block_check_failed",
184
+ block_number=receipt_block_number,
185
+ error=str(e)[:200],
186
+ )
187
+ # On error, treat as pending and retry
188
+ return ConfirmationStatus(result=ConfirmationResult.PENDING)
189
+
190
+ # Count confirmations
191
+ current_block_number = self._rpc.get_block_number()
192
+ confirmations = current_block_number - receipt_block_number + 1
193
+
194
+ # Check if confirmed with enough confirmations
195
+ if confirmations >= intent.min_confirmations:
196
+ status = receipt.get("status", 1)
197
+ if status == 1:
198
+ metrics = get_metrics()
199
+ metrics.counter(TX_CONFIRMED).inc(
200
+ chain_id=intent.chain_id,
201
+ job_id=intent.job_id,
202
+ )
203
+ # Only emit confirmation latency metric if we have actual broadcast time
204
+ # Using updated_at as fallback would give meaningless/negative values
205
+ if attempt.broadcast_at:
206
+ elapsed = time.time() - attempt.broadcast_at.timestamp()
207
+ if elapsed >= 0:
208
+ metrics.histogram(TX_CONFIRMATION_SECONDS).observe(
209
+ elapsed,
210
+ chain_id=intent.chain_id,
211
+ )
212
+ return ConfirmationStatus(
213
+ result=ConfirmationResult.CONFIRMED,
214
+ confirmations=confirmations,
215
+ block_number=receipt_block_number,
216
+ block_hash=receipt_block_hash,
217
+ gas_used=receipt.get("gasUsed"),
218
+ receipt=dict(receipt),
219
+ )
220
+ else:
221
+ metrics = get_metrics()
222
+ metrics.counter(TX_FAILED).inc(
223
+ chain_id=intent.chain_id,
224
+ job_id=intent.job_id,
225
+ reason="reverted",
226
+ )
227
+ return ConfirmationStatus(
228
+ result=ConfirmationResult.REVERTED,
229
+ confirmations=confirmations,
230
+ block_number=receipt_block_number,
231
+ block_hash=receipt_block_hash,
232
+ gas_used=receipt.get("gasUsed"),
233
+ receipt=dict(receipt),
234
+ )
235
+
236
+ # Not enough confirmations yet
237
+ return ConfirmationStatus(
238
+ result=ConfirmationResult.PENDING,
239
+ confirmations=confirmations,
240
+ block_number=receipt_block_number,
241
+ block_hash=receipt_block_hash,
242
+ )
243
+
244
+ def _is_nonce_consumed(self, intent: TxIntent, attempt: TxAttempt) -> bool:
245
+ """Check if the nonce has been consumed by another transaction.
246
+
247
+ Args:
248
+ intent: Transaction intent
249
+ attempt: Transaction attempt
250
+
251
+ Returns:
252
+ True if nonce was consumed by another tx
253
+ """
254
+ try:
255
+ # Get confirmed nonce from chain (checksum address for RPC)
256
+ signer_address = Web3.to_checksum_address(intent.signer_address)
257
+ chain_nonce = self._rpc.get_transaction_count(
258
+ signer_address,
259
+ "latest", # Use "latest" not "pending" to check confirmed
260
+ )
261
+
262
+ # If chain nonce is greater than our nonce, it was consumed
263
+ if chain_nonce > attempt.nonce:
264
+ # Verify our tx isn't the one that consumed it
265
+ receipt = self._rpc.get_transaction_receipt(attempt.tx_hash)
266
+ if receipt is None:
267
+ # Nonce consumed but not by our tx
268
+ logger.warning(
269
+ "tx.nonce_consumed_externally",
270
+ tx_hash=attempt.tx_hash,
271
+ nonce=attempt.nonce,
272
+ chain_nonce=chain_nonce,
273
+ )
274
+ return True
275
+
276
+ return False
277
+ except Exception as e:
278
+ logger.warning(
279
+ "monitor.nonce_check_failed",
280
+ error=str(e)[:200],
281
+ )
282
+ return False
283
+
284
+ def _is_stuck(self, attempt: TxAttempt) -> bool:
285
+ """Check if transaction is stuck.
286
+
287
+ Stuck is defined as:
288
+ - elapsed_time > stuck_tx_seconds OR
289
+ - blocks_since_broadcast > stuck_tx_blocks
290
+
291
+ Args:
292
+ attempt: Transaction attempt
293
+
294
+ Returns:
295
+ True if transaction is considered stuck
296
+ """
297
+ if not attempt.broadcast_block or not attempt.broadcast_at:
298
+ return False
299
+
300
+ # Check time elapsed using broadcast time (when tx was actually sent)
301
+ elapsed_seconds = time.time() - attempt.broadcast_at.timestamp()
302
+ if elapsed_seconds > self._config.stuck_tx_seconds:
303
+ return True
304
+
305
+ # Check blocks elapsed
306
+ try:
307
+ current_block = self._rpc.get_block_number()
308
+ blocks_since = current_block - attempt.broadcast_block
309
+ if blocks_since > self._config.stuck_tx_blocks:
310
+ return True
311
+ except Exception as e:
312
+ logger.warning(
313
+ "tx.stuck_check_error",
314
+ tx_hash=attempt.tx_hash,
315
+ intent_id=str(attempt.intent_id),
316
+ attempt_id=str(attempt.attempt_id),
317
+ error=str(e)[:200],
318
+ )
319
+
320
+ return False
321
+
322
+ def monitor_until_confirmed(
323
+ self,
324
+ intent: TxIntent,
325
+ attempt: TxAttempt,
326
+ poll_interval: float | None = None,
327
+ timeout: float | None = None,
328
+ ) -> ConfirmationStatus:
329
+ """Monitor transaction until confirmed, reverted, dropped, or stuck.
330
+
331
+ This is a blocking call that polls until a terminal state is reached.
332
+
333
+ Args:
334
+ intent: Transaction intent
335
+ attempt: Transaction attempt
336
+ poll_interval: Polling interval in seconds (default: config.poll_interval_seconds)
337
+ timeout: Maximum time to wait in seconds (default: config.default_deadline_seconds)
338
+
339
+ Returns:
340
+ Final confirmation status
341
+ """
342
+ poll_interval = poll_interval or self._config.poll_interval_seconds
343
+ timeout = timeout or self._config.default_deadline_seconds
344
+
345
+ start_time = time.time()
346
+
347
+ while True:
348
+ status = self.check_confirmation(intent, attempt)
349
+
350
+ # Return on terminal states
351
+ if status.result in (
352
+ ConfirmationResult.CONFIRMED,
353
+ ConfirmationResult.REVERTED,
354
+ ConfirmationResult.DROPPED,
355
+ ConfirmationResult.STUCK,
356
+ ):
357
+ return status
358
+
359
+ # Check timeout
360
+ elapsed = time.time() - start_time
361
+ if elapsed >= timeout:
362
+ logger.warning(
363
+ "monitor.timeout",
364
+ tx_hash=attempt.tx_hash,
365
+ elapsed=elapsed,
366
+ timeout=timeout,
367
+ )
368
+ return ConfirmationStatus(result=ConfirmationResult.STUCK)
369
+
370
+ # Wait before next poll
371
+ time.sleep(poll_interval)
372
+
373
+ def handle_confirmed(
374
+ self,
375
+ intent: TxIntent,
376
+ attempt: TxAttempt,
377
+ status: ConfirmationStatus,
378
+ ) -> None:
379
+ """Handle a confirmed transaction.
380
+
381
+ Updates database state for confirmed transaction:
382
+ - Mark attempt as confirmed
383
+ - Mark intent as confirmed
384
+ - Release nonce reservation
385
+
386
+ Args:
387
+ intent: Transaction intent
388
+ attempt: Transaction attempt
389
+ status: Confirmation status with receipt
390
+ """
391
+ # Update attempt status
392
+ self._db.update_attempt_status(
393
+ attempt.attempt_id,
394
+ AttemptStatus.CONFIRMED.value,
395
+ included_block=status.block_number,
396
+ )
397
+
398
+ # Update intent status
399
+ transition_intent(
400
+ self._db,
401
+ intent.intent_id,
402
+ IntentStatus.CONFIRMED,
403
+ "confirm_receipt",
404
+ chain_id=self._config.chain_id,
405
+ )
406
+
407
+ # Emit stuckness timestamps after DB transition succeeds (emit-once semantics)
408
+ now = time.time()
409
+ metrics = get_metrics()
410
+ metrics.gauge(LAST_TX_CONFIRMED_TIMESTAMP).set(now, chain_id=intent.chain_id)
411
+ metrics.gauge(LAST_INTENT_COMPLETED_TIMESTAMP).set(now, chain_id=intent.chain_id)
412
+
413
+ # Release nonce reservation (checksum address for nonce manager)
414
+ signer_address = Web3.to_checksum_address(intent.signer_address)
415
+ self._nonce_manager.release(signer_address, attempt.nonce)
416
+
417
+ if self._lifecycle and status.receipt:
418
+ self._lifecycle.on_confirmed(intent, attempt, status.receipt)
419
+
420
+ logger.info(
421
+ LogEvents.TX_CONFIRMED,
422
+ intent_id=str(intent.intent_id),
423
+ attempt_id=str(attempt.attempt_id),
424
+ tx_hash=attempt.tx_hash,
425
+ block_number=status.block_number,
426
+ confirmations=status.confirmations,
427
+ gas_used=status.gas_used,
428
+ )
429
+
430
+ def handle_reverted(
431
+ self,
432
+ intent: TxIntent,
433
+ attempt: TxAttempt,
434
+ status: ConfirmationStatus,
435
+ ) -> None:
436
+ """Handle a reverted transaction.
437
+
438
+ Updates database state for reverted transaction:
439
+ - Mark attempt as failed
440
+ - Mark intent as failed
441
+ - Release nonce reservation
442
+
443
+ Args:
444
+ intent: Transaction intent
445
+ attempt: Transaction attempt
446
+ status: Confirmation status with receipt
447
+ """
448
+ # Update attempt status
449
+ self._db.update_attempt_status(
450
+ attempt.attempt_id,
451
+ AttemptStatus.FAILED.value,
452
+ included_block=status.block_number,
453
+ error_code="execution_reverted",
454
+ error_detail="Transaction reverted on-chain",
455
+ )
456
+
457
+ # Update intent status
458
+ transition_intent(
459
+ self._db,
460
+ intent.intent_id,
461
+ IntentStatus.FAILED,
462
+ "execution_reverted",
463
+ chain_id=self._config.chain_id,
464
+ )
465
+
466
+ # Release nonce reservation (checksum address for nonce manager)
467
+ signer_address = Web3.to_checksum_address(intent.signer_address)
468
+ self._nonce_manager.release(signer_address, attempt.nonce)
469
+
470
+ if self._lifecycle:
471
+ self._lifecycle.on_failed(
472
+ intent,
473
+ attempt,
474
+ RuntimeError("Transaction reverted on-chain"),
475
+ failure_type=FailureType.TX_REVERTED,
476
+ failure_stage=FailureStage.POST_BROADCAST,
477
+ )
478
+
479
+ logger.error(
480
+ LogEvents.TX_FAILED,
481
+ intent_id=str(intent.intent_id),
482
+ attempt_id=str(attempt.attempt_id),
483
+ tx_hash=attempt.tx_hash,
484
+ block_number=status.block_number,
485
+ error="execution_reverted",
486
+ )
487
+
488
+ def handle_dropped(
489
+ self,
490
+ intent: TxIntent,
491
+ attempt: TxAttempt,
492
+ ) -> None:
493
+ """Handle a dropped transaction (nonce consumed externally).
494
+
495
+ The nonce was used by another transaction, so this attempt is dead.
496
+ The intent should be marked as failed.
497
+
498
+ Args:
499
+ intent: Transaction intent
500
+ attempt: Transaction attempt
501
+ """
502
+ # Update attempt status
503
+ self._db.update_attempt_status(
504
+ attempt.attempt_id,
505
+ AttemptStatus.FAILED.value,
506
+ error_code="nonce_consumed",
507
+ error_detail="Nonce was consumed by another transaction",
508
+ )
509
+
510
+ # Update intent status
511
+ transition_intent(
512
+ self._db,
513
+ intent.intent_id,
514
+ IntentStatus.FAILED,
515
+ "nonce_consumed",
516
+ chain_id=self._config.chain_id,
517
+ )
518
+
519
+ # Mark nonce as orphaned (it was used elsewhere) - checksum address
520
+ signer_address = Web3.to_checksum_address(intent.signer_address)
521
+ self._db.update_nonce_reservation_status(
522
+ self._config.chain_id,
523
+ signer_address,
524
+ attempt.nonce,
525
+ NonceStatus.ORPHANED.value,
526
+ )
527
+
528
+ if self._lifecycle:
529
+ self._lifecycle.on_failed(
530
+ intent,
531
+ attempt,
532
+ RuntimeError("Nonce was consumed by another transaction"),
533
+ failure_type=FailureType.NONCE_CONSUMED,
534
+ failure_stage=FailureStage.POST_BROADCAST,
535
+ )
536
+
537
+ logger.warning(
538
+ LogEvents.TX_FAILED,
539
+ intent_id=str(intent.intent_id),
540
+ attempt_id=str(attempt.attempt_id),
541
+ tx_hash=attempt.tx_hash,
542
+ error="nonce_consumed",
543
+ )
544
+
545
+ def get_pending_attempts(self) -> list[tuple[TxIntent, TxAttempt]]:
546
+ """Get all pending intents with their latest attempts.
547
+
548
+ Returns intents that are in 'pending' status with their broadcast attempts.
549
+ Used for batch monitoring.
550
+
551
+ Returns:
552
+ List of (intent, attempt) tuples to monitor
553
+ """
554
+ pending = []
555
+
556
+ # Get all pending intents
557
+ intents = self._db.get_intents_by_status(
558
+ IntentStatus.PENDING.value,
559
+ chain_id=self._config.chain_id,
560
+ )
561
+
562
+ for intent in intents:
563
+ attempt = self._db.get_latest_attempt_for_intent(intent.intent_id)
564
+ if attempt and attempt.tx_hash:
565
+ pending.append((intent, attempt))
566
+
567
+ return pending
568
+
569
+ def monitor_all_pending(self) -> dict[str, int]:
570
+ """Monitor all pending transactions and update their status.
571
+
572
+ Single pass through all pending transactions. Should be called
573
+ periodically by the main runner.
574
+
575
+ Returns:
576
+ Dict with counts of each result type
577
+ """
578
+ results = {
579
+ "confirmed": 0,
580
+ "reverted": 0,
581
+ "dropped": 0,
582
+ "stuck": 0,
583
+ "pending": 0,
584
+ }
585
+
586
+ pending = self.get_pending_attempts()
587
+
588
+ for intent, attempt in pending:
589
+ try:
590
+ status = self.check_confirmation(intent, attempt)
591
+
592
+ if status.result == ConfirmationResult.CONFIRMED:
593
+ self.handle_confirmed(intent, attempt, status)
594
+ results["confirmed"] += 1
595
+
596
+ elif status.result == ConfirmationResult.REVERTED:
597
+ self.handle_reverted(intent, attempt, status)
598
+ results["reverted"] += 1
599
+
600
+ elif status.result == ConfirmationResult.DROPPED:
601
+ self.handle_dropped(intent, attempt)
602
+ results["dropped"] += 1
603
+
604
+ elif status.result == ConfirmationResult.STUCK:
605
+ # Don't handle stuck here - let replacement logic handle it
606
+ results["stuck"] += 1
607
+
608
+ else:
609
+ results["pending"] += 1
610
+
611
+ except (RPCError, DatabaseError, OSError, ValueError) as e:
612
+ # Expected monitoring errors - log and retry next cycle
613
+ logger.error(
614
+ "monitor.check_failed",
615
+ intent_id=str(intent.intent_id),
616
+ attempt_id=str(attempt.attempt_id),
617
+ error=str(e)[:200],
618
+ error_type=type(e).__name__,
619
+ )
620
+ results["pending"] += 1
621
+
622
+ if any(v > 0 for k, v in results.items() if k != "pending"):
623
+ logger.info(
624
+ "monitor.batch_complete",
625
+ **results,
626
+ )
627
+
628
+ return results