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