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,632 @@
1
+ """Reorg detection and handling.
2
+
3
+ Implements reorg detection from SPEC 5.2:
4
+ - Maintain block_hash_history window
5
+ - Compare stored hash at anchor height with chain
6
+ - Binary search to find last matching height
7
+ - Rewind and reprocess on reorg detection
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, replace
13
+ from typing import TYPE_CHECKING, Callable
14
+
15
+ from brawny.alerts.health import health_alert
16
+ from brawny.logging import LogEvents, get_logger
17
+ from brawny.metrics import REORGS_DETECTED, get_metrics
18
+ from brawny.model.enums import AttemptStatus, IntentStatus, NonceStatus
19
+ from brawny.tx.intent import transition_intent
20
+
21
+ if TYPE_CHECKING:
22
+ from brawny.db.base import Database
23
+ from brawny.lifecycle import LifecycleDispatcher
24
+ from brawny._rpc.manager import RPCManager
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class ReorgResult:
31
+ """Result of reorg detection."""
32
+
33
+ reorg_detected: bool
34
+ reorg_depth: int = 0
35
+ last_good_height: int | None = None
36
+ intents_reverted: int = 0
37
+ attempts_reverted: int = 0
38
+ rewind_reason: str | None = None
39
+ anchor_height: int | None = None
40
+ anchor_hash_db: str | None = None
41
+ anchor_hash_chain: str | None = None
42
+ history_min_height: int | None = None
43
+ history_max_height: int | None = None
44
+ finality_confirmations: int | None = None
45
+ pause: bool = False
46
+ last_processed: int | None = None
47
+
48
+
49
+ class ReorgDetector:
50
+ """Reorg detector using block hash history comparison.
51
+
52
+ Algorithm:
53
+ 1. Select anchor height (last_processed - reorg_depth)
54
+ 2. Compare stored hash at anchor to current chain hash
55
+ 3. If mismatch, binary search for last matching height
56
+ 4. Rewind state and handle affected intents
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ db: Database,
62
+ rpc: RPCManager,
63
+ chain_id: int,
64
+ reorg_depth: int = 32,
65
+ block_hash_history_size: int = 256,
66
+ finality_confirmations: int = 0,
67
+ lifecycle: "LifecycleDispatcher | None" = None,
68
+ deep_reorg_alert_enabled: bool = True,
69
+ health_send_fn: Callable[..., None] | None = None,
70
+ health_chat_id: str | None = None,
71
+ health_cooldown: int = 1800,
72
+ ) -> None:
73
+ """Initialize reorg detector.
74
+
75
+ Args:
76
+ db: Database connection
77
+ rpc: RPC manager
78
+ chain_id: Chain ID
79
+ reorg_depth: Blocks back to check for reorg
80
+ block_hash_history_size: Size of hash history window
81
+ """
82
+ self._db = db
83
+ self._rpc = rpc
84
+ self._chain_id = chain_id
85
+ self._reorg_depth = reorg_depth
86
+ self._history_size = block_hash_history_size
87
+ self._finality_confirmations = max(0, finality_confirmations)
88
+ self._lifecycle = lifecycle
89
+ self._deep_reorg_alert_enabled = deep_reorg_alert_enabled
90
+ self._health_send_fn = health_send_fn
91
+ self._health_chat_id = health_chat_id
92
+ self._health_cooldown = health_cooldown
93
+
94
+ def check(self, current_block: int) -> ReorgResult:
95
+ """Check for reorg at the current block height.
96
+
97
+ Args:
98
+ current_block: Current block being processed
99
+
100
+ Returns:
101
+ ReorgResult with detection status
102
+ """
103
+ # Get block state
104
+ block_state = self._db.get_block_state(self._chain_id)
105
+ if block_state is None:
106
+ return ReorgResult(reorg_detected=False)
107
+
108
+ last_processed = block_state.last_processed_block_number
109
+ history_min = self._db.get_oldest_block_in_history(self._chain_id)
110
+ history_max = self._db.get_latest_block_in_history(self._chain_id)
111
+
112
+ # Calculate anchor height
113
+ anchor_height = max(0, last_processed - self._reorg_depth)
114
+
115
+ # Get stored hash at anchor
116
+ stored_hash = self._db.get_block_hash_at_height(self._chain_id, anchor_height)
117
+ anchor_missing = False
118
+ if stored_hash is None:
119
+ # No history at anchor - check if we have any history
120
+ if history_min is None:
121
+ return ReorgResult(reorg_detected=False)
122
+ if history_max is None or anchor_height > history_max:
123
+ anchor_height = history_min
124
+ stored_hash = self._db.get_block_hash_at_height(self._chain_id, anchor_height)
125
+ if stored_hash is None:
126
+ return ReorgResult(reorg_detected=False)
127
+ anchor_missing = True
128
+ elif anchor_height >= history_min:
129
+ # Expected history missing -> possible corruption
130
+ logger.error(
131
+ "reorg.history_missing",
132
+ anchor_height=anchor_height,
133
+ history_min=history_min,
134
+ )
135
+ return ReorgResult(
136
+ reorg_detected=True,
137
+ reorg_depth=last_processed - history_min + 1,
138
+ last_good_height=None,
139
+ rewind_reason="deep_reorg",
140
+ anchor_height=anchor_height,
141
+ history_min_height=history_min,
142
+ history_max_height=history_max,
143
+ finality_confirmations=self._finality_confirmations,
144
+ pause=True,
145
+ last_processed=last_processed,
146
+ )
147
+ else:
148
+ anchor_height = history_min
149
+ stored_hash = self._db.get_block_hash_at_height(self._chain_id, anchor_height)
150
+ if stored_hash is None:
151
+ return ReorgResult(reorg_detected=False)
152
+ anchor_missing = True
153
+ if not stored_hash.startswith("0x"):
154
+ stored_hash = f"0x{stored_hash}"
155
+
156
+ # Get current chain hash at anchor
157
+ try:
158
+ block = self._rpc.get_block(anchor_height)
159
+ if block is None:
160
+ return ReorgResult(reorg_detected=False)
161
+
162
+ chain_hash = block.get("hash")
163
+ if chain_hash is None:
164
+ logger.warning(
165
+ "reorg.missing_block_hash",
166
+ block_number=anchor_height,
167
+ )
168
+ return ReorgResult(reorg_detected=False)
169
+ if isinstance(chain_hash, bytes):
170
+ chain_hash = chain_hash.hex()
171
+ if not chain_hash.startswith("0x"):
172
+ chain_hash = f"0x{chain_hash}"
173
+ except Exception as e:
174
+ logger.warning(
175
+ "reorg.check_failed",
176
+ anchor_height=anchor_height,
177
+ error=str(e),
178
+ )
179
+ return ReorgResult(reorg_detected=False)
180
+
181
+ # Compare hashes
182
+ stored_normalized = stored_hash.lower()
183
+ chain_normalized = chain_hash.lower()
184
+
185
+ if stored_normalized == chain_normalized:
186
+ # No reorg
187
+ return ReorgResult(reorg_detected=False)
188
+
189
+ # Reorg detected!
190
+ rewind_reason = "missing_history" if anchor_missing else "anchor_mismatch"
191
+ logger.warning(
192
+ LogEvents.BLOCK_REORG_DETECTED,
193
+ anchor_height=anchor_height,
194
+ stored_hash=stored_hash[:18],
195
+ chain_hash=chain_hash[:18],
196
+ )
197
+ metrics = get_metrics()
198
+ metrics.counter(REORGS_DETECTED).inc(
199
+ chain_id=self._chain_id,
200
+ )
201
+
202
+ # Find last good height via binary search
203
+ last_good_height = self._find_last_good_height(anchor_height, last_processed)
204
+ oldest = history_min
205
+ if oldest is not None and last_good_height < oldest:
206
+ finality_floor = max(0, last_processed - self._finality_confirmations)
207
+ if anchor_missing and last_good_height < finality_floor:
208
+ logger.error(
209
+ LogEvents.BLOCK_REORG_DEEP,
210
+ oldest_known=oldest,
211
+ history_size=self._history_size,
212
+ )
213
+ if self._lifecycle and self._deep_reorg_alert_enabled:
214
+ self._lifecycle.on_deep_reorg(oldest, self._history_size, last_processed)
215
+ return ReorgResult(
216
+ reorg_detected=True,
217
+ reorg_depth=last_processed - (oldest - 1),
218
+ last_good_height=None,
219
+ rewind_reason="deep_reorg",
220
+ anchor_height=anchor_height,
221
+ anchor_hash_db=stored_hash,
222
+ anchor_hash_chain=chain_hash,
223
+ history_min_height=oldest,
224
+ history_max_height=history_max,
225
+ finality_confirmations=self._finality_confirmations,
226
+ pause=True,
227
+ last_processed=last_processed,
228
+ )
229
+
230
+ logger.warning(
231
+ "reorg.insufficient_history",
232
+ oldest_known=oldest,
233
+ last_good_height=last_good_height,
234
+ history_size=self._history_size,
235
+ )
236
+ last_good_height = oldest
237
+
238
+ # Handle impossible state: mismatch at anchor but last_good >= anchor
239
+ # This happens with sparse hash history - delete stale anchor hash
240
+ if rewind_reason == "anchor_mismatch" and last_good_height >= anchor_height:
241
+ logger.warning(
242
+ "reorg.stale_hash_detected",
243
+ anchor_height=anchor_height,
244
+ last_good_height=last_good_height,
245
+ stored_hash=stored_hash[:18],
246
+ chain_hash=chain_hash[:18],
247
+ )
248
+ # Delete the stale hash at anchor and set last_good to anchor - 1
249
+ self._db.delete_block_hash_at_height(self._chain_id, anchor_height)
250
+ last_good_height = anchor_height - 1
251
+
252
+ reorg_depth = last_processed - last_good_height
253
+
254
+ logger.warning(
255
+ LogEvents.BLOCK_REORG_REWIND,
256
+ last_good_height=last_good_height,
257
+ reorg_depth=reorg_depth,
258
+ )
259
+
260
+ return ReorgResult(
261
+ reorg_detected=True,
262
+ reorg_depth=reorg_depth,
263
+ last_good_height=last_good_height,
264
+ rewind_reason=rewind_reason,
265
+ anchor_height=anchor_height,
266
+ anchor_hash_db=stored_hash,
267
+ anchor_hash_chain=chain_hash,
268
+ history_min_height=history_min,
269
+ history_max_height=history_max,
270
+ finality_confirmations=self._finality_confirmations,
271
+ last_processed=last_processed,
272
+ )
273
+
274
+ def _find_last_good_height(self, low: int, high: int) -> int:
275
+ """Binary search to find last matching block height.
276
+
277
+ Args:
278
+ low: Lower bound (known bad)
279
+ high: Upper bound (known bad)
280
+
281
+ Returns:
282
+ Last good block height
283
+ """
284
+ oldest = self._db.get_oldest_block_in_history(self._chain_id)
285
+ if oldest is None:
286
+ return low
287
+
288
+ # Start from the known bad anchor and search forward
289
+ # We need to find where the chain diverged
290
+ left = max(oldest, low)
291
+ right = high
292
+
293
+ last_good = left - 1 # Assume nothing matches if search fails
294
+
295
+ while left <= right:
296
+ mid = (left + right) // 2
297
+
298
+ stored = self._db.get_block_hash_at_height(self._chain_id, mid)
299
+ if stored is None:
300
+ # No history here, move right
301
+ left = mid + 1
302
+ continue
303
+
304
+ try:
305
+ block = self._rpc.get_block(mid)
306
+ if block is None:
307
+ left = mid + 1
308
+ continue
309
+
310
+ chain_hash = block["hash"]
311
+ if isinstance(chain_hash, bytes):
312
+ chain_hash = chain_hash.hex()
313
+ if not chain_hash.startswith("0x"):
314
+ chain_hash = f"0x{chain_hash}"
315
+ except Exception:
316
+ left = mid + 1
317
+ continue
318
+
319
+ if stored.lower() == chain_hash.lower():
320
+ # Match - reorg is after this point
321
+ last_good = mid
322
+ left = mid + 1
323
+ else:
324
+ # Mismatch - reorg is at or before this point
325
+ right = mid - 1
326
+
327
+ return last_good
328
+ def rewind(self, reorg_result: ReorgResult) -> ReorgResult:
329
+ """Rewind state using the centralized recovery contract."""
330
+ recovery = ReorgRecovery(
331
+ db=self._db,
332
+ rpc=self._rpc,
333
+ chain_id=self._chain_id,
334
+ lifecycle=self._lifecycle,
335
+ finality_confirmations=self._finality_confirmations,
336
+ health_send_fn=self._health_send_fn,
337
+ health_chat_id=self._health_chat_id,
338
+ health_cooldown=self._health_cooldown,
339
+ )
340
+ return recovery.rewind(reorg_result)
341
+
342
+ def handle_deep_reorg(self) -> None:
343
+ """Handle a reorg deeper than our history window.
344
+
345
+ This is a critical situation - emit error and rewind to oldest known block.
346
+ """
347
+ oldest = self._db.get_oldest_block_in_history(self._chain_id)
348
+
349
+ logger.error(
350
+ LogEvents.BLOCK_REORG_DEEP,
351
+ oldest_known=oldest,
352
+ history_size=self._history_size,
353
+ )
354
+
355
+ if oldest is not None:
356
+ recovery = ReorgRecovery(
357
+ db=self._db,
358
+ rpc=self._rpc,
359
+ chain_id=self._chain_id,
360
+ lifecycle=self._lifecycle,
361
+ finality_confirmations=self._finality_confirmations,
362
+ health_send_fn=self._health_send_fn,
363
+ health_chat_id=self._health_chat_id,
364
+ health_cooldown=self._health_cooldown,
365
+ )
366
+ recovery.rewind(
367
+ ReorgResult(
368
+ reorg_detected=True,
369
+ reorg_depth=0,
370
+ last_good_height=oldest,
371
+ rewind_reason="deep_reorg",
372
+ history_min_height=oldest,
373
+ history_max_height=self._db.get_latest_block_in_history(self._chain_id),
374
+ finality_confirmations=self._finality_confirmations,
375
+ )
376
+ )
377
+
378
+
379
+ class ReorgRecovery:
380
+ """Centralized reorg recovery contract.
381
+
382
+ Preconditions:
383
+ - caller holds poller lock
384
+ - no concurrent monitor execution
385
+
386
+ Postconditions:
387
+ - last_processed_block <= to_height
388
+ - no confirmed attempt exists above last_processed_block
389
+ - nonce state consistent with attempts
390
+ """
391
+
392
+ def __init__(
393
+ self,
394
+ db: Database,
395
+ rpc: RPCManager,
396
+ chain_id: int,
397
+ lifecycle: "LifecycleDispatcher | None" = None,
398
+ finality_confirmations: int = 0,
399
+ health_send_fn: Callable[..., None] | None = None,
400
+ health_chat_id: str | None = None,
401
+ health_cooldown: int = 1800,
402
+ ) -> None:
403
+ self._db = db
404
+ self._rpc = rpc
405
+ self._chain_id = chain_id
406
+ self._lifecycle = lifecycle
407
+ self._finality_confirmations = max(0, finality_confirmations)
408
+ self._health_send_fn = health_send_fn
409
+ self._health_chat_id = health_chat_id
410
+ self._health_cooldown = health_cooldown
411
+
412
+ def rewind(self, reorg_result: ReorgResult) -> ReorgResult:
413
+ """Rewind state to the last good height."""
414
+ to_height = reorg_result.last_good_height
415
+ if to_height is None:
416
+ return reorg_result
417
+
418
+ block_state = self._db.get_block_state(self._chain_id)
419
+ if block_state is None:
420
+ raise RuntimeError("reorg.rewind_missing_block_state")
421
+ last_processed = block_state.last_processed_block_number
422
+
423
+ deleted_hashes = 0
424
+ intents_reverted = 0
425
+ attempts_reverted = 0
426
+ rewind_hash = None
427
+
428
+ if to_height == last_processed:
429
+ reorg_result = replace(reorg_result, last_good_height=to_height)
430
+ self._log_summary(
431
+ reorg_result,
432
+ last_processed_before=last_processed,
433
+ last_processed_after=last_processed,
434
+ deleted_hashes=0,
435
+ intents_reverted=0,
436
+ attempts_reverted=0,
437
+ )
438
+ return replace(
439
+ reorg_result,
440
+ intents_reverted=0,
441
+ attempts_reverted=0,
442
+ )
443
+
444
+ try:
445
+ with self._db.transaction():
446
+ deleted_hashes = self._db.delete_block_hashes_above(self._chain_id, to_height)
447
+
448
+ rewind_hash = self._db.get_block_hash_at_height(self._chain_id, to_height)
449
+ if rewind_hash is None:
450
+ try:
451
+ block = self._rpc.get_block(to_height)
452
+ if block:
453
+ rewind_hash = block["hash"]
454
+ if isinstance(rewind_hash, bytes):
455
+ rewind_hash = rewind_hash.hex()
456
+ except Exception:
457
+ rewind_hash = None
458
+
459
+ if rewind_hash is None:
460
+ logger.warning(
461
+ "reorg.rewind_hash_missing",
462
+ to_height=to_height,
463
+ )
464
+ rewind_hash = "0x0"
465
+
466
+ self._db.upsert_block_state(self._chain_id, to_height, rewind_hash or "0x0")
467
+
468
+ intents_reverted, attempts_reverted = self._revert_reorged_intents(to_height)
469
+ self._assert_no_confirmed_above(to_height)
470
+ except Exception as e:
471
+ logger.error(
472
+ "reorg.rewind_failed",
473
+ to_height=to_height,
474
+ error=str(e)[:200],
475
+ )
476
+ health_alert(
477
+ component="brawny.scheduler.reorg",
478
+ chain_id=self._chain_id,
479
+ error=e,
480
+ action="Reorg rewind failed; inspect DB state",
481
+ db_dialect=self._db.dialect,
482
+ send_fn=self._health_send_fn,
483
+ health_chat_id=self._health_chat_id,
484
+ cooldown_seconds=self._health_cooldown,
485
+ )
486
+ raise
487
+
488
+ self._log_summary(
489
+ reorg_result,
490
+ last_processed_before=last_processed,
491
+ last_processed_after=to_height,
492
+ deleted_hashes=deleted_hashes,
493
+ intents_reverted=intents_reverted,
494
+ attempts_reverted=attempts_reverted,
495
+ )
496
+
497
+ return replace(
498
+ reorg_result,
499
+ reorg_detected=True,
500
+ reorg_depth=max(0, last_processed - to_height),
501
+ last_good_height=to_height,
502
+ intents_reverted=intents_reverted,
503
+ attempts_reverted=attempts_reverted,
504
+ last_processed=last_processed,
505
+ )
506
+
507
+ def _revert_reorged_intents(self, to_height: int) -> tuple[int, int]:
508
+ """Revert intents confirmed in blocks above the rewind height."""
509
+ confirmed_intents = self._db.get_intents_by_status(
510
+ IntentStatus.CONFIRMED.value,
511
+ chain_id=self._chain_id,
512
+ )
513
+
514
+ intents_reverted = 0
515
+ attempts_reverted = 0
516
+ for intent in confirmed_intents:
517
+ attempts = self._db.get_attempts_for_intent(intent.intent_id)
518
+ if not attempts:
519
+ continue
520
+
521
+ confirmed_attempts = [
522
+ a for a in attempts
523
+ if a.status == AttemptStatus.CONFIRMED and a.included_block
524
+ and a.included_block > to_height
525
+ ]
526
+ if not confirmed_attempts:
527
+ continue
528
+
529
+ attempt = max(confirmed_attempts, key=lambda a: a.included_block or 0)
530
+ if attempt.included_block and attempt.included_block > to_height:
531
+ transition_intent(
532
+ self._db,
533
+ intent.intent_id,
534
+ IntentStatus.PENDING,
535
+ "reorg_revert",
536
+ chain_id=self._chain_id,
537
+ )
538
+
539
+ self._db.update_attempt_status(
540
+ attempt.attempt_id,
541
+ AttemptStatus.PENDING.value,
542
+ )
543
+ attempts_reverted += 1
544
+ try:
545
+ signer_address = intent.signer_address.lower()
546
+ reservation = self._db.get_nonce_reservation(
547
+ self._chain_id,
548
+ signer_address,
549
+ attempt.nonce,
550
+ )
551
+ if reservation is None:
552
+ self._db.create_nonce_reservation(
553
+ self._chain_id,
554
+ signer_address,
555
+ attempt.nonce,
556
+ status=NonceStatus.IN_FLIGHT.value,
557
+ intent_id=intent.intent_id,
558
+ )
559
+ else:
560
+ self._db.update_nonce_reservation_status(
561
+ self._chain_id,
562
+ signer_address,
563
+ attempt.nonce,
564
+ NonceStatus.IN_FLIGHT.value,
565
+ intent_id=intent.intent_id,
566
+ )
567
+ except Exception as e:
568
+ logger.warning(
569
+ "reorg.nonce_reconcile_failed",
570
+ intent_id=str(intent.intent_id),
571
+ nonce=attempt.nonce,
572
+ error=str(e)[:200],
573
+ )
574
+
575
+ if self._lifecycle:
576
+ self._lifecycle.on_reorged(intent, attempt, to_height)
577
+
578
+ logger.warning(
579
+ LogEvents.INTENT_REORG,
580
+ intent_id=str(intent.intent_id),
581
+ attempt_id=str(attempt.attempt_id),
582
+ old_block=attempt.included_block,
583
+ reorg_height=to_height,
584
+ )
585
+
586
+ intents_reverted += 1
587
+
588
+ return intents_reverted, attempts_reverted
589
+
590
+ def _assert_no_confirmed_above(self, to_height: int) -> None:
591
+ confirmed_intents = self._db.get_intents_by_status(
592
+ IntentStatus.CONFIRMED.value,
593
+ chain_id=self._chain_id,
594
+ )
595
+ for intent in confirmed_intents:
596
+ attempts = self._db.get_attempts_for_intent(intent.intent_id)
597
+ for attempt in attempts:
598
+ if (
599
+ attempt.status == AttemptStatus.CONFIRMED
600
+ and attempt.included_block
601
+ and attempt.included_block > to_height
602
+ ):
603
+ raise RuntimeError(
604
+ f"reorg.invariant_failed intent={intent.intent_id} included_block={attempt.included_block} to_height={to_height}"
605
+ )
606
+
607
+ def _log_summary(
608
+ self,
609
+ reorg_result: ReorgResult,
610
+ *,
611
+ last_processed_before: int,
612
+ last_processed_after: int,
613
+ deleted_hashes: int,
614
+ intents_reverted: int,
615
+ attempts_reverted: int,
616
+ ) -> None:
617
+ logger.warning(
618
+ "reorg.summary",
619
+ last_processed_before=last_processed_before,
620
+ last_processed_after=last_processed_after,
621
+ anchor_height=reorg_result.anchor_height,
622
+ last_good_height=reorg_result.last_good_height,
623
+ anchor_hash_db=reorg_result.anchor_hash_db,
624
+ anchor_hash_chain=reorg_result.anchor_hash_chain,
625
+ history_min_height=reorg_result.history_min_height,
626
+ history_max_height=reorg_result.history_max_height,
627
+ intents_reverted=intents_reverted,
628
+ attempts_reverted=attempts_reverted,
629
+ deleted_hash_count=deleted_hashes,
630
+ finality_confirmations=self._finality_confirmations,
631
+ rewind_reason=reorg_result.rewind_reason,
632
+ )