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