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
brawny/tx/nonce.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Centralized nonce manager for transaction execution.
|
|
2
|
+
|
|
3
|
+
Implements the nonce management strategy from SPEC 8:
|
|
4
|
+
- Reserve nonce with SERIALIZABLE isolation
|
|
5
|
+
- Nonce status transitions (reserved → in_flight → released/orphaned)
|
|
6
|
+
- Reconciliation loop for startup and periodic sync
|
|
7
|
+
- SQLite-specific locking for development
|
|
8
|
+
|
|
9
|
+
Jobs NEVER allocate or set nonces - the nonce manager owns all nonce operations.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from typing import TYPE_CHECKING, Generator
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from web3 import Web3
|
|
19
|
+
|
|
20
|
+
from brawny.logging import LogEvents, get_logger
|
|
21
|
+
from brawny.model.enums import NonceStatus
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from brawny.db.base import Database
|
|
25
|
+
from brawny.model.types import NonceReservation
|
|
26
|
+
from brawny._rpc.manager import RPCManager
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NonceManager:
|
|
32
|
+
"""Centralized nonce manager for transaction execution.
|
|
33
|
+
|
|
34
|
+
Provides atomic nonce reservation with database-backed persistence.
|
|
35
|
+
Handles multiple in-flight nonces per signer to prevent global blocking.
|
|
36
|
+
|
|
37
|
+
Thread-safe: Uses database transactions for concurrency control.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
db: Database,
|
|
43
|
+
rpc: RPCManager,
|
|
44
|
+
chain_id: int,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Initialize nonce manager.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
db: Database connection
|
|
50
|
+
rpc: RPC manager for chain state queries
|
|
51
|
+
chain_id: Chain ID for nonce tracking
|
|
52
|
+
"""
|
|
53
|
+
self._db = db
|
|
54
|
+
self._rpc = rpc
|
|
55
|
+
self._chain_id = chain_id
|
|
56
|
+
|
|
57
|
+
def reserve_nonce(
|
|
58
|
+
self,
|
|
59
|
+
signer_address: str,
|
|
60
|
+
intent_id: UUID | None = None,
|
|
61
|
+
) -> int:
|
|
62
|
+
"""Reserve the next available nonce for a signer.
|
|
63
|
+
|
|
64
|
+
Algorithm:
|
|
65
|
+
1. Lock signer row (or create if not exists)
|
|
66
|
+
2. Fetch chain pending nonce
|
|
67
|
+
3. Calculate base nonce as max(chain_nonce, db_next_nonce)
|
|
68
|
+
4. Find next available nonce (skip existing reservations)
|
|
69
|
+
5. Create reservation and update signer's next_nonce
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
signer_address: Ethereum address of the signer
|
|
73
|
+
intent_id: Optional intent ID to associate with reservation
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The reserved nonce value
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
Exception: If reservation fails
|
|
80
|
+
"""
|
|
81
|
+
signer_address = signer_address.lower()
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
chain_nonce = self._rpc.get_transaction_count(
|
|
85
|
+
Web3.to_checksum_address(signer_address), block_identifier="pending"
|
|
86
|
+
)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.warning(
|
|
89
|
+
"nonce.chain_fetch_failed",
|
|
90
|
+
signer=signer_address,
|
|
91
|
+
error=str(e),
|
|
92
|
+
)
|
|
93
|
+
chain_nonce = None
|
|
94
|
+
|
|
95
|
+
nonce = self._db.reserve_nonce_atomic(
|
|
96
|
+
chain_id=self._chain_id,
|
|
97
|
+
address=signer_address,
|
|
98
|
+
chain_nonce=chain_nonce,
|
|
99
|
+
intent_id=intent_id,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
logger.debug(
|
|
103
|
+
LogEvents.NONCE_RESERVE,
|
|
104
|
+
signer=signer_address,
|
|
105
|
+
nonce=nonce,
|
|
106
|
+
chain_nonce=chain_nonce,
|
|
107
|
+
intent_id=str(intent_id) if intent_id else None,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return nonce
|
|
111
|
+
|
|
112
|
+
def mark_in_flight(
|
|
113
|
+
self,
|
|
114
|
+
signer_address: str,
|
|
115
|
+
nonce: int,
|
|
116
|
+
intent_id: UUID,
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""Mark a nonce reservation as in-flight (after broadcast).
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
signer_address: Ethereum address of the signer
|
|
122
|
+
nonce: The nonce value
|
|
123
|
+
intent_id: Intent ID to associate
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if updated successfully
|
|
127
|
+
"""
|
|
128
|
+
signer_address = signer_address.lower()
|
|
129
|
+
return self._db.update_nonce_reservation_status(
|
|
130
|
+
chain_id=self._chain_id,
|
|
131
|
+
address=signer_address,
|
|
132
|
+
nonce=nonce,
|
|
133
|
+
status=NonceStatus.IN_FLIGHT.value,
|
|
134
|
+
intent_id=intent_id,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def release(
|
|
138
|
+
self,
|
|
139
|
+
signer_address: str,
|
|
140
|
+
nonce: int,
|
|
141
|
+
) -> bool:
|
|
142
|
+
"""Release a nonce reservation (after confirm/fail/abandon).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
signer_address: Ethereum address of the signer
|
|
146
|
+
nonce: The nonce value
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if released successfully
|
|
150
|
+
"""
|
|
151
|
+
signer_address = signer_address.lower()
|
|
152
|
+
return self._db.release_nonce_reservation(
|
|
153
|
+
self._chain_id, signer_address, nonce
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@contextmanager
|
|
157
|
+
def reserved(
|
|
158
|
+
self,
|
|
159
|
+
signer_address: str,
|
|
160
|
+
intent_id: UUID | None = None,
|
|
161
|
+
) -> Generator[int, None, None]:
|
|
162
|
+
"""Context manager for nonce reservation with automatic release on failure.
|
|
163
|
+
|
|
164
|
+
Automatically releases the nonce if an exception occurs within the context.
|
|
165
|
+
On success path, caller is responsible for calling mark_in_flight() to
|
|
166
|
+
transition the nonce to in-flight status.
|
|
167
|
+
|
|
168
|
+
Usage:
|
|
169
|
+
with nonce_manager.reserved(signer) as nonce:
|
|
170
|
+
# Build and sign transaction with nonce
|
|
171
|
+
# If exception raised, nonce is automatically released
|
|
172
|
+
|
|
173
|
+
# After context, caller should call mark_in_flight() on success
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
signer_address: Ethereum address of the signer
|
|
177
|
+
intent_id: Optional intent ID to associate with reservation
|
|
178
|
+
|
|
179
|
+
Yields:
|
|
180
|
+
Reserved nonce value
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
Exception: Re-raises any exception after releasing the nonce
|
|
184
|
+
"""
|
|
185
|
+
signer_address = signer_address.lower()
|
|
186
|
+
nonce = self.reserve_nonce(signer_address, intent_id)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
yield nonce
|
|
190
|
+
except Exception:
|
|
191
|
+
# Release nonce on any exception
|
|
192
|
+
self.release(signer_address, nonce)
|
|
193
|
+
logger.debug(
|
|
194
|
+
"nonce.released_on_error",
|
|
195
|
+
signer=signer_address,
|
|
196
|
+
nonce=nonce,
|
|
197
|
+
)
|
|
198
|
+
raise
|
|
199
|
+
|
|
200
|
+
def mark_orphaned(
|
|
201
|
+
self,
|
|
202
|
+
signer_address: str,
|
|
203
|
+
nonce: int,
|
|
204
|
+
) -> bool:
|
|
205
|
+
"""Mark a nonce as orphaned (nonce used but no tx found).
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
signer_address: Ethereum address of the signer
|
|
209
|
+
nonce: The nonce value
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if updated successfully
|
|
213
|
+
"""
|
|
214
|
+
signer_address = signer_address.lower()
|
|
215
|
+
updated = self._db.update_nonce_reservation_status(
|
|
216
|
+
chain_id=self._chain_id,
|
|
217
|
+
address=signer_address,
|
|
218
|
+
nonce=nonce,
|
|
219
|
+
status=NonceStatus.ORPHANED.value,
|
|
220
|
+
)
|
|
221
|
+
if updated:
|
|
222
|
+
logger.warning(
|
|
223
|
+
LogEvents.NONCE_ORPHANED,
|
|
224
|
+
signer=signer_address,
|
|
225
|
+
nonce=nonce,
|
|
226
|
+
)
|
|
227
|
+
return updated
|
|
228
|
+
|
|
229
|
+
def get_reservation(
|
|
230
|
+
self,
|
|
231
|
+
signer_address: str,
|
|
232
|
+
nonce: int,
|
|
233
|
+
) -> NonceReservation | None:
|
|
234
|
+
"""Get a specific nonce reservation.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
signer_address: Ethereum address of the signer
|
|
238
|
+
nonce: The nonce value
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Reservation if found, None otherwise
|
|
242
|
+
"""
|
|
243
|
+
return self._db.get_nonce_reservation(
|
|
244
|
+
self._chain_id, signer_address.lower(), nonce
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def get_active_reservations(
|
|
248
|
+
self,
|
|
249
|
+
signer_address: str,
|
|
250
|
+
) -> list[NonceReservation]:
|
|
251
|
+
"""Get all active (non-released) reservations for a signer.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
signer_address: Ethereum address of the signer
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
List of active reservations
|
|
258
|
+
"""
|
|
259
|
+
all_reservations = self._db.get_reservations_for_signer(
|
|
260
|
+
self._chain_id, signer_address.lower()
|
|
261
|
+
)
|
|
262
|
+
return [
|
|
263
|
+
r for r in all_reservations
|
|
264
|
+
if r.status not in (NonceStatus.RELEASED,)
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
def reconcile(self, signer_address: str | None = None) -> dict[str, int]:
|
|
268
|
+
"""Reconcile nonce reservations with chain state.
|
|
269
|
+
|
|
270
|
+
Run at startup and periodically to:
|
|
271
|
+
- Reset next_nonce when gap detected (CRITICAL for recovery)
|
|
272
|
+
- Update signer's synced chain nonce
|
|
273
|
+
- Mark stale reservations as released or orphaned
|
|
274
|
+
- Clean up confirmed/used nonces
|
|
275
|
+
- Release gap reservations (nonces >= chain_nonce with no tx in mempool)
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
signer_address: Optional specific signer to reconcile.
|
|
279
|
+
If None, reconciles all signers.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Dictionary with reconciliation stats
|
|
283
|
+
"""
|
|
284
|
+
stats = {
|
|
285
|
+
"signers_checked": 0,
|
|
286
|
+
"nonces_released": 0,
|
|
287
|
+
"nonces_orphaned": 0,
|
|
288
|
+
"orphans_cleaned": 0,
|
|
289
|
+
"next_nonce_reset": 0,
|
|
290
|
+
"gap_reservations_released": 0,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if signer_address:
|
|
294
|
+
signers = [self._db.get_signer_state(self._chain_id, signer_address.lower())]
|
|
295
|
+
signers = [s for s in signers if s is not None]
|
|
296
|
+
else:
|
|
297
|
+
signers = self._db.get_all_signers(self._chain_id)
|
|
298
|
+
|
|
299
|
+
for signer in signers:
|
|
300
|
+
stats["signers_checked"] += 1
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
# Get current chain nonce
|
|
304
|
+
chain_nonce = self._rpc.get_transaction_count(
|
|
305
|
+
Web3.to_checksum_address(signer.signer_address), block_identifier="pending"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Update signer's synced chain nonce
|
|
309
|
+
self._db.update_signer_chain_nonce(
|
|
310
|
+
self._chain_id, signer.signer_address, chain_nonce
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# CRITICAL FIX: Reset next_nonce when gap detected
|
|
314
|
+
# Without this, reserve_nonce_atomic() keeps returning stale nonces
|
|
315
|
+
if chain_nonce < signer.next_nonce:
|
|
316
|
+
gap_size = signer.next_nonce - chain_nonce
|
|
317
|
+
logger.warning(
|
|
318
|
+
"nonce.gap_reset",
|
|
319
|
+
signer=signer.signer_address,
|
|
320
|
+
old_next_nonce=signer.next_nonce,
|
|
321
|
+
chain_nonce=chain_nonce,
|
|
322
|
+
gap_size=gap_size,
|
|
323
|
+
)
|
|
324
|
+
self._db.update_signer_next_nonce(
|
|
325
|
+
self._chain_id, signer.signer_address, chain_nonce
|
|
326
|
+
)
|
|
327
|
+
stats["next_nonce_reset"] += 1
|
|
328
|
+
|
|
329
|
+
# Release all non-released reservations >= chain_nonce
|
|
330
|
+
# These are "gap" reservations whose txs are no longer in mempool
|
|
331
|
+
gap_reservations = self._db.get_reservations_for_signer(
|
|
332
|
+
self._chain_id, signer.signer_address
|
|
333
|
+
)
|
|
334
|
+
for reservation in gap_reservations:
|
|
335
|
+
if reservation.status == NonceStatus.RELEASED:
|
|
336
|
+
continue
|
|
337
|
+
if reservation.nonce >= chain_nonce:
|
|
338
|
+
# This reservation is in the gap - tx doesn't exist
|
|
339
|
+
self.release(signer.signer_address, reservation.nonce)
|
|
340
|
+
stats["gap_reservations_released"] += 1
|
|
341
|
+
logger.debug(
|
|
342
|
+
"nonce.gap_reservation_released",
|
|
343
|
+
signer=signer.signer_address,
|
|
344
|
+
nonce=reservation.nonce,
|
|
345
|
+
intent_id=str(reservation.intent_id) if reservation.intent_id else None,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Get stale reservations (nonce < chain_nonce)
|
|
349
|
+
stale_reservations = self._db.get_reservations_below_nonce(
|
|
350
|
+
self._chain_id, signer.signer_address, chain_nonce
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
for reservation in stale_reservations:
|
|
354
|
+
if reservation.status == NonceStatus.RELEASED:
|
|
355
|
+
# Already released, skip
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
if reservation.intent_id:
|
|
359
|
+
# Has associated intent - check if confirmed
|
|
360
|
+
attempt = self._db.get_latest_attempt_for_intent(
|
|
361
|
+
reservation.intent_id
|
|
362
|
+
)
|
|
363
|
+
if attempt and attempt.status.value == "confirmed":
|
|
364
|
+
# Confirmed - release the reservation
|
|
365
|
+
self.release(signer.signer_address, reservation.nonce)
|
|
366
|
+
stats["nonces_released"] += 1
|
|
367
|
+
else:
|
|
368
|
+
# Not confirmed but nonce is used - orphaned
|
|
369
|
+
self.mark_orphaned(signer.signer_address, reservation.nonce)
|
|
370
|
+
stats["nonces_orphaned"] += 1
|
|
371
|
+
else:
|
|
372
|
+
# No intent - just release
|
|
373
|
+
self.release(signer.signer_address, reservation.nonce)
|
|
374
|
+
stats["nonces_released"] += 1
|
|
375
|
+
|
|
376
|
+
logger.info(
|
|
377
|
+
LogEvents.NONCE_RECONCILE,
|
|
378
|
+
signer=signer.signer_address,
|
|
379
|
+
chain_nonce=chain_nonce,
|
|
380
|
+
stale_count=len(stale_reservations),
|
|
381
|
+
next_nonce_was_reset=chain_nonce < signer.next_nonce,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.error(
|
|
386
|
+
"nonce.reconcile.error",
|
|
387
|
+
signer=signer.signer_address,
|
|
388
|
+
error=str(e),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Cleanup old orphaned reservations (24+ hours old)
|
|
392
|
+
stats["orphans_cleaned"] = self.cleanup_orphaned()
|
|
393
|
+
|
|
394
|
+
return stats
|
|
395
|
+
|
|
396
|
+
def cleanup_orphaned(self, older_than_hours: int = 24) -> int:
|
|
397
|
+
"""Delete orphaned nonce reservations older than specified hours.
|
|
398
|
+
|
|
399
|
+
Orphaned reservations occur when a nonce was used but no transaction
|
|
400
|
+
was found on-chain. These are safe to delete after some time.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
older_than_hours: Delete orphans older than this (default: 24h)
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Number of deleted reservations
|
|
407
|
+
"""
|
|
408
|
+
deleted = self._db.cleanup_orphaned_nonces(self._chain_id, older_than_hours)
|
|
409
|
+
if deleted > 0:
|
|
410
|
+
logger.info(
|
|
411
|
+
"nonce.orphans_cleaned",
|
|
412
|
+
chain_id=self._chain_id,
|
|
413
|
+
deleted=deleted,
|
|
414
|
+
older_than_hours=older_than_hours,
|
|
415
|
+
)
|
|
416
|
+
return deleted
|
|
417
|
+
|
|
418
|
+
def sync_from_chain(self, signer_address: str) -> int:
|
|
419
|
+
"""Sync signer state from chain and return current pending nonce.
|
|
420
|
+
|
|
421
|
+
Use this during startup or after external transactions.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
signer_address: Ethereum address of the signer
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Current pending nonce from chain
|
|
428
|
+
"""
|
|
429
|
+
signer_address = signer_address.lower()
|
|
430
|
+
chain_nonce = self._rpc.get_transaction_count(
|
|
431
|
+
Web3.to_checksum_address(signer_address), block_identifier="pending"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Upsert signer with chain nonce
|
|
435
|
+
self._db.upsert_signer(
|
|
436
|
+
chain_id=self._chain_id,
|
|
437
|
+
address=signer_address,
|
|
438
|
+
next_nonce=chain_nonce,
|
|
439
|
+
last_synced_chain_nonce=chain_nonce,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
logger.info(
|
|
443
|
+
"nonce.synced_from_chain",
|
|
444
|
+
signer=signer_address,
|
|
445
|
+
chain_nonce=chain_nonce,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return chain_nonce
|
|
449
|
+
|
|
450
|
+
def force_reset(self, signer_address: str) -> int:
|
|
451
|
+
"""Force reset nonce state to match chain. Returns new next_nonce.
|
|
452
|
+
|
|
453
|
+
USE WITH CAUTION: May cause issues if dropped txs later mine.
|
|
454
|
+
|
|
455
|
+
This will:
|
|
456
|
+
- Query current chain pending nonce
|
|
457
|
+
- Reset local next_nonce to match chain
|
|
458
|
+
- Release all reservations with nonce >= chain_pending_nonce
|
|
459
|
+
- Clear gap tracking
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
signer_address: Ethereum address of the signer
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
The new next_nonce (equal to chain pending nonce)
|
|
466
|
+
"""
|
|
467
|
+
signer_address = signer_address.lower()
|
|
468
|
+
chain_nonce = self._rpc.get_transaction_count(
|
|
469
|
+
Web3.to_checksum_address(signer_address), block_identifier="pending"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Release all reservations at or above chain nonce
|
|
473
|
+
reservations = self._db.get_reservations_for_signer(
|
|
474
|
+
self._chain_id, signer_address
|
|
475
|
+
)
|
|
476
|
+
released_count = 0
|
|
477
|
+
for r in reservations:
|
|
478
|
+
if r.nonce >= chain_nonce and r.status in (
|
|
479
|
+
NonceStatus.RESERVED,
|
|
480
|
+
NonceStatus.IN_FLIGHT,
|
|
481
|
+
):
|
|
482
|
+
self.release(signer_address, r.nonce)
|
|
483
|
+
released_count += 1
|
|
484
|
+
|
|
485
|
+
# Reset next_nonce
|
|
486
|
+
self._db.update_signer_next_nonce(self._chain_id, signer_address, chain_nonce)
|
|
487
|
+
|
|
488
|
+
# Clear gap tracking
|
|
489
|
+
self._db.clear_gap_started_at(self._chain_id, signer_address)
|
|
490
|
+
|
|
491
|
+
logger.warning(
|
|
492
|
+
"nonce.force_reset",
|
|
493
|
+
signer=signer_address,
|
|
494
|
+
new_next_nonce=chain_nonce,
|
|
495
|
+
released_reservations=released_count,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return chain_nonce
|