brawny 0.1.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. brawny-0.1.13.dist-info/top_level.txt +1 -0
brawny/db/base.py ADDED
@@ -0,0 +1,986 @@
1
+ """Database abstraction layer for brawny.
2
+
3
+ Provides a unified interface for both PostgreSQL and SQLite backends.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from typing import TYPE_CHECKING, Any, Iterator, Literal
13
+ from uuid import UUID
14
+
15
+ if TYPE_CHECKING:
16
+ from brawny.model.errors import ErrorInfo, FailureType
17
+ from brawny.model.types import (
18
+ BroadcastInfo,
19
+ GasParams,
20
+ JobConfig,
21
+ NonceReservation,
22
+ SignerState,
23
+ Transaction,
24
+ TxAttempt,
25
+ TxIntent,
26
+ )
27
+
28
+
29
+ IsolationLevel = Literal["SERIALIZABLE", "REPEATABLE READ", "READ COMMITTED", "READ UNCOMMITTED"]
30
+
31
+
32
+ @dataclass
33
+ class BlockState:
34
+ """Block processing state."""
35
+
36
+ chain_id: int
37
+ last_processed_block_number: int
38
+ last_processed_block_hash: str
39
+ created_at: datetime
40
+ updated_at: datetime
41
+
42
+
43
+ @dataclass
44
+ class BlockHashEntry:
45
+ """Block hash history entry for reorg detection."""
46
+
47
+ id: int
48
+ chain_id: int
49
+ block_number: int
50
+ block_hash: str
51
+ inserted_at: datetime
52
+
53
+
54
+ @dataclass
55
+ class ABICacheEntry:
56
+ """Cached ABI entry."""
57
+
58
+ chain_id: int
59
+ address: str
60
+ abi_json: str
61
+ source: str
62
+ resolved_at: datetime
63
+
64
+
65
+ @dataclass
66
+ class ProxyCacheEntry:
67
+ """Cached proxy resolution entry."""
68
+
69
+ chain_id: int
70
+ proxy_address: str
71
+ implementation_address: str
72
+ resolved_at: datetime
73
+
74
+
75
+ class Database(ABC):
76
+ """Abstract database interface.
77
+
78
+ Implementations must provide thread-safe connection management
79
+ and proper transaction isolation.
80
+ """
81
+
82
+ @abstractmethod
83
+ def connect(self) -> None:
84
+ """Establish database connection."""
85
+ ...
86
+
87
+ @abstractmethod
88
+ def close(self) -> None:
89
+ """Close database connection and cleanup resources."""
90
+ ...
91
+
92
+ @abstractmethod
93
+ def is_connected(self) -> bool:
94
+ """Check if database is connected."""
95
+ ...
96
+
97
+ @abstractmethod
98
+ @contextmanager
99
+ def transaction(
100
+ self, isolation_level: IsolationLevel | None = None
101
+ ) -> Iterator[None]:
102
+ """Context manager for database transactions.
103
+
104
+ Args:
105
+ isolation_level: Optional isolation level override
106
+ """
107
+ ...
108
+
109
+ @abstractmethod
110
+ def execute(
111
+ self,
112
+ query: str,
113
+ params: tuple[Any, ...] | dict[str, Any] | None = None,
114
+ ) -> None:
115
+ """Execute a query without returning results."""
116
+ ...
117
+
118
+ @abstractmethod
119
+ def execute_returning(
120
+ self,
121
+ query: str,
122
+ params: tuple[Any, ...] | dict[str, Any] | None = None,
123
+ ) -> list[dict[str, Any]]:
124
+ """Execute a query and return all results as dicts."""
125
+ ...
126
+
127
+ @abstractmethod
128
+ def execute_one(
129
+ self,
130
+ query: str,
131
+ params: tuple[Any, ...] | dict[str, Any] | None = None,
132
+ ) -> dict[str, Any] | None:
133
+ """Execute a query and return a single result or None."""
134
+ ...
135
+
136
+ @abstractmethod
137
+ def execute_returning_rowcount(
138
+ self,
139
+ query: str,
140
+ params: tuple[Any, ...] | dict[str, Any] | None = None,
141
+ ) -> int:
142
+ """Execute a query and return affected rowcount."""
143
+ ...
144
+
145
+ # =========================================================================
146
+ # Block State Operations
147
+ # =========================================================================
148
+
149
+ @abstractmethod
150
+ def get_block_state(self, chain_id: int) -> BlockState | None:
151
+ """Get the current block processing state."""
152
+ ...
153
+
154
+ @abstractmethod
155
+ def upsert_block_state(
156
+ self,
157
+ chain_id: int,
158
+ block_number: int,
159
+ block_hash: str,
160
+ ) -> None:
161
+ """Update or insert block processing state."""
162
+ ...
163
+
164
+ @abstractmethod
165
+ def get_block_hash_at_height(
166
+ self, chain_id: int, block_number: int
167
+ ) -> str | None:
168
+ """Get stored block hash at a specific height."""
169
+ ...
170
+
171
+ @abstractmethod
172
+ def insert_block_hash(
173
+ self, chain_id: int, block_number: int, block_hash: str
174
+ ) -> None:
175
+ """Insert a block hash into history."""
176
+ ...
177
+
178
+ @abstractmethod
179
+ def delete_block_hashes_above(self, chain_id: int, block_number: int) -> int:
180
+ """Delete block hashes above a certain height (for reorg rewind)."""
181
+ ...
182
+
183
+ @abstractmethod
184
+ def delete_block_hash_at_height(self, chain_id: int, block_number: int) -> bool:
185
+ """Delete a specific block hash (for stale hash cleanup)."""
186
+ ...
187
+
188
+ @abstractmethod
189
+ def cleanup_old_block_hashes(self, chain_id: int, keep_count: int) -> int:
190
+ """Delete old block hashes beyond the history window."""
191
+ ...
192
+
193
+ @abstractmethod
194
+ def get_oldest_block_in_history(self, chain_id: int) -> int | None:
195
+ """Get the oldest block number in hash history."""
196
+ ...
197
+
198
+ @abstractmethod
199
+ def get_latest_block_in_history(self, chain_id: int) -> int | None:
200
+ """Get the newest block number in hash history."""
201
+ ...
202
+
203
+ @abstractmethod
204
+ def get_inflight_intent_count(
205
+ self, chain_id: int, job_id: str, signer_address: str
206
+ ) -> int:
207
+ """Count inflight intents (created/claimed/sending/pending) for job+signer."""
208
+ ...
209
+
210
+ @abstractmethod
211
+ def get_inflight_intents_for_scope(
212
+ self,
213
+ chain_id: int,
214
+ job_id: str,
215
+ signer_address: str,
216
+ to_address: str,
217
+ ) -> list[dict[str, Any]]:
218
+ """List inflight intents (created/claimed/sending/pending) for job+signer+to."""
219
+ ...
220
+
221
+ # =========================================================================
222
+ # Job Operations
223
+ # =========================================================================
224
+
225
+ @abstractmethod
226
+ def get_job(self, job_id: str) -> JobConfig | None:
227
+ """Get job configuration by ID."""
228
+ ...
229
+
230
+ @abstractmethod
231
+ def get_enabled_jobs(self) -> list[JobConfig]:
232
+ """Get all enabled jobs ordered by job_id."""
233
+ ...
234
+
235
+ @abstractmethod
236
+ def list_all_jobs(self) -> list[JobConfig]:
237
+ """Get all jobs (enabled and disabled) ordered by job_id."""
238
+ ...
239
+
240
+ @abstractmethod
241
+ def upsert_job(
242
+ self,
243
+ job_id: str,
244
+ job_name: str,
245
+ check_interval_blocks: int,
246
+ enabled: bool = True,
247
+ ) -> None:
248
+ """Insert or update job configuration."""
249
+ ...
250
+
251
+ @abstractmethod
252
+ def update_job_checked(
253
+ self, job_id: str, block_number: int, triggered: bool = False
254
+ ) -> None:
255
+ """Update job's last checked/triggered block numbers."""
256
+ ...
257
+
258
+ @abstractmethod
259
+ def set_job_enabled(self, job_id: str, enabled: bool) -> bool:
260
+ """Enable or disable a job. Returns True if job exists."""
261
+ ...
262
+
263
+ @abstractmethod
264
+ def delete_job(self, job_id: str) -> bool:
265
+ """Delete a job from the database. Returns True if job existed."""
266
+ ...
267
+
268
+ @abstractmethod
269
+ def get_job_kv(self, job_id: str, key: str) -> Any | None:
270
+ """Get a value from job's key-value store."""
271
+ ...
272
+
273
+ @abstractmethod
274
+ def set_job_kv(self, job_id: str, key: str, value: Any) -> None:
275
+ """Set a value in job's key-value store."""
276
+ ...
277
+
278
+ @abstractmethod
279
+ def delete_job_kv(self, job_id: str, key: str) -> bool:
280
+ """Delete a key from job's key-value store."""
281
+ ...
282
+
283
+ # =========================================================================
284
+ # Signer & Nonce Operations
285
+ # =========================================================================
286
+
287
+ @abstractmethod
288
+ def get_signer_state(self, chain_id: int, address: str) -> SignerState | None:
289
+ """Get signer state including next nonce."""
290
+ ...
291
+
292
+ @abstractmethod
293
+ def get_all_signers(self, chain_id: int) -> list[SignerState]:
294
+ """Get all signers for a chain."""
295
+ ...
296
+
297
+ @abstractmethod
298
+ def upsert_signer(
299
+ self,
300
+ chain_id: int,
301
+ address: str,
302
+ next_nonce: int,
303
+ last_synced_chain_nonce: int | None = None,
304
+ ) -> None:
305
+ """Insert or update signer state."""
306
+ ...
307
+
308
+ @abstractmethod
309
+ def update_signer_next_nonce(
310
+ self, chain_id: int, address: str, next_nonce: int
311
+ ) -> None:
312
+ """Update signer's next nonce value."""
313
+ ...
314
+
315
+ @abstractmethod
316
+ def update_signer_chain_nonce(
317
+ self, chain_id: int, address: str, chain_nonce: int
318
+ ) -> None:
319
+ """Update signer's last synced chain nonce."""
320
+ ...
321
+
322
+ @abstractmethod
323
+ def set_gap_started_at(
324
+ self, chain_id: int, address: str, started_at: datetime
325
+ ) -> None:
326
+ """Record when gap blocking started for a signer."""
327
+ ...
328
+
329
+ @abstractmethod
330
+ def clear_gap_started_at(self, chain_id: int, address: str) -> None:
331
+ """Clear gap tracking (gap resolved or force reset)."""
332
+ ...
333
+
334
+ @abstractmethod
335
+ def get_signer_by_alias(self, chain_id: int, alias: str) -> SignerState | None:
336
+ """Get signer by alias. Returns None if not found."""
337
+ ...
338
+
339
+ @abstractmethod
340
+ def reserve_nonce_atomic(
341
+ self,
342
+ chain_id: int,
343
+ address: str,
344
+ chain_nonce: int | None,
345
+ intent_id: UUID | None = None,
346
+ ) -> int:
347
+ """Atomically reserve the next available nonce for a signer."""
348
+ ...
349
+
350
+ @abstractmethod
351
+ def get_nonce_reservation(
352
+ self, chain_id: int, address: str, nonce: int
353
+ ) -> NonceReservation | None:
354
+ """Get a specific nonce reservation."""
355
+ ...
356
+
357
+ @abstractmethod
358
+ def get_reservations_for_signer(
359
+ self, chain_id: int, address: str, status: str | None = None
360
+ ) -> list[NonceReservation]:
361
+ """Get all reservations for a signer, optionally filtered by status."""
362
+ ...
363
+
364
+ @abstractmethod
365
+ def get_reservations_below_nonce(
366
+ self, chain_id: int, address: str, nonce: int
367
+ ) -> list[NonceReservation]:
368
+ """Get reservations with nonce less than given value."""
369
+ ...
370
+
371
+ @abstractmethod
372
+ def create_nonce_reservation(
373
+ self,
374
+ chain_id: int,
375
+ address: str,
376
+ nonce: int,
377
+ status: str = "reserved",
378
+ intent_id: UUID | None = None,
379
+ ) -> NonceReservation:
380
+ """Create a new nonce reservation."""
381
+ ...
382
+
383
+ @abstractmethod
384
+ def update_nonce_reservation_status(
385
+ self,
386
+ chain_id: int,
387
+ address: str,
388
+ nonce: int,
389
+ status: str,
390
+ intent_id: UUID | None = None,
391
+ ) -> bool:
392
+ """Update nonce reservation status. Returns True if updated."""
393
+ ...
394
+
395
+ @abstractmethod
396
+ def release_nonce_reservation(
397
+ self, chain_id: int, address: str, nonce: int
398
+ ) -> bool:
399
+ """Release (mark as released) a nonce reservation."""
400
+ ...
401
+
402
+ @abstractmethod
403
+ def cleanup_orphaned_nonces(
404
+ self, chain_id: int, older_than_hours: int = 24
405
+ ) -> int:
406
+ """Delete orphaned nonce reservations older than specified hours.
407
+
408
+ Args:
409
+ chain_id: Chain ID to cleanup
410
+ older_than_hours: Delete orphaned reservations older than this (default: 24h)
411
+
412
+ Returns:
413
+ Number of deleted reservations
414
+ """
415
+ ...
416
+
417
+ # =========================================================================
418
+ # Intent Operations
419
+ # =========================================================================
420
+
421
+ @abstractmethod
422
+ def create_intent(
423
+ self,
424
+ intent_id: UUID,
425
+ job_id: str,
426
+ chain_id: int,
427
+ signer_address: str,
428
+ idempotency_key: str,
429
+ to_address: str,
430
+ data: str | None,
431
+ value_wei: str,
432
+ gas_limit: int | None,
433
+ max_fee_per_gas: str | None,
434
+ max_priority_fee_per_gas: str | None,
435
+ min_confirmations: int,
436
+ deadline_ts: datetime | None,
437
+ broadcast_group: str | None = None,
438
+ broadcast_endpoints: list[str] | None = None,
439
+ ) -> TxIntent | None:
440
+ """Create a new intent. Returns None if idempotency_key exists."""
441
+ ...
442
+
443
+ @abstractmethod
444
+ def get_intent(self, intent_id: UUID) -> TxIntent | None:
445
+ """Get an intent by ID."""
446
+ ...
447
+
448
+ @abstractmethod
449
+ def get_intent_by_idempotency_key(
450
+ self,
451
+ chain_id: int,
452
+ signer_address: str,
453
+ idempotency_key: str,
454
+ ) -> TxIntent | None:
455
+ """Get an intent by idempotency key (scoped to chain and signer)."""
456
+ ...
457
+
458
+ @abstractmethod
459
+ def get_intents_by_status(
460
+ self,
461
+ status: str | list[str],
462
+ chain_id: int | None = None,
463
+ job_id: str | None = None,
464
+ limit: int = 100,
465
+ ) -> list[TxIntent]:
466
+ """Get intents by status."""
467
+ ...
468
+
469
+ @abstractmethod
470
+ def list_intents_filtered(
471
+ self,
472
+ status: str | None = None,
473
+ job_id: str | None = None,
474
+ limit: int = 50,
475
+ ) -> list[dict[str, Any]]:
476
+ """List intents with optional filters, returning raw dict data for CLI display."""
477
+ ...
478
+
479
+ @abstractmethod
480
+ def get_active_intent_count(self, job_id: str, chain_id: int | None = None) -> int:
481
+ """Count active intents for a job (created/claimed/sending/pending)."""
482
+ ...
483
+
484
+ @abstractmethod
485
+ def get_pending_intent_count(self, chain_id: int | None = None) -> int:
486
+ """Count active intents across all jobs (created/claimed/sending/pending)."""
487
+ ...
488
+
489
+ @abstractmethod
490
+ def get_backing_off_intent_count(self, chain_id: int | None = None) -> int:
491
+ """Count intents with retry_after in the future."""
492
+ ...
493
+
494
+ @abstractmethod
495
+ def get_oldest_pending_intent_age(self, chain_id: int) -> float | None:
496
+ """Get age in seconds of the oldest pending intent.
497
+
498
+ Considers intents in: CREATED, PENDING, CLAIMED, SENDING status.
499
+
500
+ Returns:
501
+ Age in seconds, or None if no pending intents.
502
+ """
503
+ ...
504
+
505
+ @abstractmethod
506
+ def list_intent_inconsistencies(
507
+ self,
508
+ max_age_seconds: int,
509
+ limit: int = 100,
510
+ chain_id: int | None = None,
511
+ ) -> list[dict[str, Any]]:
512
+ """List intents with inconsistent state/metadata."""
513
+ ...
514
+
515
+ @abstractmethod
516
+ def list_sending_intents_older_than(
517
+ self,
518
+ max_age_seconds: int,
519
+ limit: int = 100,
520
+ chain_id: int | None = None,
521
+ ) -> list[TxIntent]:
522
+ """List sending intents older than a threshold."""
523
+ ...
524
+
525
+ @abstractmethod
526
+ def claim_next_intent(
527
+ self,
528
+ claim_token: str,
529
+ claimed_by: str | None = None,
530
+ ) -> TxIntent | None:
531
+ """Claim the next available intent for processing."""
532
+ ...
533
+
534
+ @abstractmethod
535
+ def update_intent_status(
536
+ self,
537
+ intent_id: UUID,
538
+ status: str,
539
+ claim_token: str | None = None,
540
+ ) -> bool:
541
+ """Update intent status. Returns True if updated."""
542
+ ...
543
+
544
+ @abstractmethod
545
+ def update_intent_status_if(
546
+ self,
547
+ intent_id: UUID,
548
+ status: str,
549
+ expected_status: str | list[str],
550
+ ) -> bool:
551
+ """Update intent status only if current status matches expected."""
552
+ ...
553
+
554
+ @abstractmethod
555
+ def transition_intent_status(
556
+ self,
557
+ intent_id: UUID,
558
+ from_statuses: list[str],
559
+ to_status: str,
560
+ ) -> tuple[bool, str | None]:
561
+ """Atomically transition intent status, clearing claim if leaving CLAIMED.
562
+
563
+ The claim fields (claim_token, claimed_at, claimed_by) are cleared
564
+ automatically when:
565
+ - The actual previous status is 'claimed', AND
566
+ - The new status is NOT 'claimed'
567
+
568
+ This prevents clearing claim on claimed->claimed transitions.
569
+
570
+ Returns:
571
+ (success, old_status) - old_status is the actual previous status,
572
+ or None if no row matched the WHERE clause.
573
+ """
574
+ ...
575
+
576
+ @abstractmethod
577
+ def update_intent_signer(self, intent_id: UUID, signer_address: str) -> bool:
578
+ """Update intent signer address (for alias resolution)."""
579
+ ...
580
+
581
+ @abstractmethod
582
+ def release_intent_claim(self, intent_id: UUID) -> bool:
583
+ """Release an intent claim (revert to created status)."""
584
+ ...
585
+
586
+ @abstractmethod
587
+ def release_intent_claim_if_token(self, intent_id: UUID, claim_token: str) -> bool:
588
+ """Release claim only if claim_token matches. Returns True if released."""
589
+ ...
590
+
591
+ @abstractmethod
592
+ def clear_intent_claim(self, intent_id: UUID) -> bool:
593
+ """Clear claim token and claimed_at without changing status."""
594
+ ...
595
+
596
+ @abstractmethod
597
+ def set_intent_retry_after(self, intent_id: UUID, retry_after: datetime | None) -> bool:
598
+ """Set intent retry-after timestamp (null clears backoff)."""
599
+ ...
600
+
601
+ @abstractmethod
602
+ def increment_intent_retry_count(self, intent_id: UUID) -> int:
603
+ """Atomically increment retry count and return new value."""
604
+ ...
605
+
606
+ @abstractmethod
607
+ def release_stale_intent_claims(self, max_age_seconds: int) -> int:
608
+ """Release stale intent claims with no attempts. Returns count released."""
609
+ ...
610
+
611
+ @abstractmethod
612
+ def abandon_intent(self, intent_id: UUID) -> bool:
613
+ """Mark an intent as abandoned."""
614
+ ...
615
+
616
+ @abstractmethod
617
+ def get_pending_intents_for_signer(
618
+ self, chain_id: int, address: str
619
+ ) -> list[TxIntent]:
620
+ """Get pending intents for a signer (for reconciliation)."""
621
+ ...
622
+
623
+ # =========================================================================
624
+ # Attempt Operations
625
+ # =========================================================================
626
+
627
+ @abstractmethod
628
+ def create_attempt(
629
+ self,
630
+ attempt_id: UUID,
631
+ intent_id: UUID,
632
+ nonce: int,
633
+ gas_params_json: str,
634
+ status: str = "signed",
635
+ tx_hash: str | None = None,
636
+ replaces_attempt_id: UUID | None = None,
637
+ broadcast_group: str | None = None,
638
+ endpoint_url: str | None = None,
639
+ binding: tuple[str, list[str]] | None = None,
640
+ ) -> TxAttempt:
641
+ """Create a new transaction attempt.
642
+
643
+ Args:
644
+ attempt_id: Unique attempt ID
645
+ intent_id: Parent intent ID
646
+ nonce: Transaction nonce
647
+ gas_params_json: Gas parameters as JSON
648
+ status: Initial status (default: "signed")
649
+ tx_hash: Transaction hash if known
650
+ replaces_attempt_id: ID of attempt being replaced
651
+ broadcast_group: RPC group used for broadcast
652
+ endpoint_url: Endpoint URL that accepted the transaction
653
+ binding: If provided (first broadcast), persist binding atomically.
654
+ Tuple of (group_name, endpoint_list).
655
+ """
656
+ ...
657
+
658
+ @abstractmethod
659
+ def get_attempt(self, attempt_id: UUID) -> TxAttempt | None:
660
+ """Get an attempt by ID."""
661
+ ...
662
+
663
+ @abstractmethod
664
+ def get_attempts_for_intent(self, intent_id: UUID) -> list[TxAttempt]:
665
+ """Get all attempts for an intent."""
666
+ ...
667
+
668
+ @abstractmethod
669
+ def get_latest_attempt_for_intent(self, intent_id: UUID) -> TxAttempt | None:
670
+ """Get the most recent attempt for an intent."""
671
+ ...
672
+
673
+ @abstractmethod
674
+ def get_attempt_by_tx_hash(self, tx_hash: str) -> TxAttempt | None:
675
+ """Get an attempt by transaction hash."""
676
+ ...
677
+
678
+ @abstractmethod
679
+ def update_attempt_status(
680
+ self,
681
+ attempt_id: UUID,
682
+ status: str,
683
+ tx_hash: str | None = None,
684
+ broadcast_block: int | None = None,
685
+ broadcast_at: datetime | None = None,
686
+ included_block: int | None = None,
687
+ error_code: str | None = None,
688
+ error_detail: str | None = None,
689
+ ) -> bool:
690
+ """Update attempt status and related fields."""
691
+ ...
692
+
693
+ # =========================================================================
694
+ # Transaction Operations (NEW - replaces Intent/Attempt in Phase 2+)
695
+ #
696
+ # IMPORTANT: Transaction is the only durable execution model.
697
+ # Do not add attempt-related methods here.
698
+ # =========================================================================
699
+
700
+ @abstractmethod
701
+ def create_tx(
702
+ self,
703
+ tx_id: UUID,
704
+ job_id: str,
705
+ chain_id: int,
706
+ idempotency_key: str,
707
+ signer_address: str,
708
+ to_address: str,
709
+ data: str | None,
710
+ value_wei: str,
711
+ min_confirmations: int,
712
+ deadline_ts: datetime | None,
713
+ gas_params: GasParams | None = None,
714
+ ) -> Transaction | None:
715
+ """Create a new transaction.
716
+
717
+ Returns None if idempotency_key already exists (idempotency).
718
+ Initial status is always 'created'.
719
+ """
720
+ ...
721
+
722
+ @abstractmethod
723
+ def get_tx(self, tx_id: UUID) -> Transaction | None:
724
+ """Get a transaction by ID."""
725
+ ...
726
+
727
+ @abstractmethod
728
+ def get_tx_by_idempotency_key(
729
+ self,
730
+ chain_id: int,
731
+ signer_address: str,
732
+ idempotency_key: str,
733
+ ) -> Transaction | None:
734
+ """Get a transaction by idempotency key (scoped to chain and signer)."""
735
+ ...
736
+
737
+ @abstractmethod
738
+ def get_tx_by_hash(self, tx_hash: str) -> Transaction | None:
739
+ """Get a transaction by current tx hash.
740
+
741
+ NOTE: Does NOT search tx_hash_history. Only matches current_tx_hash.
742
+ """
743
+ ...
744
+
745
+ @abstractmethod
746
+ def list_pending_txs(
747
+ self,
748
+ chain_id: int | None = None,
749
+ job_id: str | None = None,
750
+ ) -> list[Transaction]:
751
+ """List transactions in CREATED or BROADCAST status."""
752
+ ...
753
+
754
+ @abstractmethod
755
+ def claim_tx(self, claim_token: str) -> Transaction | None:
756
+ """Claim the next CREATED transaction for processing.
757
+
758
+ This is a lease, not ownership. The claim gates execution only.
759
+ Status remains CREATED while claimed - no "claimed" status.
760
+ """
761
+ ...
762
+
763
+ @abstractmethod
764
+ def set_tx_broadcast(
765
+ self,
766
+ tx_id: UUID,
767
+ tx_hash: str,
768
+ nonce: int,
769
+ gas_params: GasParams,
770
+ broadcast_block: int,
771
+ broadcast_info: BroadcastInfo | None = None,
772
+ ) -> bool:
773
+ """Record initial broadcast.
774
+
775
+ Sets status=BROADCAST, creates first tx_hash_history record.
776
+ Returns True if successful, False if tx not found or wrong status.
777
+ """
778
+ ...
779
+
780
+ @abstractmethod
781
+ def set_tx_replaced(
782
+ self,
783
+ tx_id: UUID,
784
+ new_tx_hash: str,
785
+ gas_params: GasParams,
786
+ broadcast_block: int,
787
+ reason: str = "fee_bump",
788
+ ) -> bool:
789
+ """Record replacement broadcast.
790
+
791
+ Appends to tx_hash_history, updates current_tx_hash, increments
792
+ replacement_count. Status remains BROADCAST.
793
+
794
+ Returns True if successful, False if tx not found or wrong status.
795
+ """
796
+ ...
797
+
798
+ @abstractmethod
799
+ def set_tx_confirmed(
800
+ self,
801
+ tx_id: UUID,
802
+ included_block: int,
803
+ ) -> bool:
804
+ """Mark transaction confirmed.
805
+
806
+ Sets status=CONFIRMED, included_block, confirmed_at.
807
+ Returns True if successful, False if tx not found or wrong status.
808
+ """
809
+ ...
810
+
811
+ @abstractmethod
812
+ def set_tx_failed(
813
+ self,
814
+ tx_id: UUID,
815
+ failure_type: FailureType,
816
+ error_info: ErrorInfo | None = None,
817
+ ) -> bool:
818
+ """Mark transaction failed.
819
+
820
+ Sets status=FAILED, failure_type, error_info_json.
821
+ Returns True if successful, False if tx not found or already terminal.
822
+ """
823
+ ...
824
+
825
+ @abstractmethod
826
+ def release_stale_tx_claims(self, max_age_seconds: int) -> int:
827
+ """Release claims older than threshold.
828
+
829
+ Returns count of claims released.
830
+ """
831
+ ...
832
+
833
+ # =========================================================================
834
+ # ABI Cache Operations
835
+ # =========================================================================
836
+
837
+ @abstractmethod
838
+ def get_cached_abi(self, chain_id: int, address: str) -> ABICacheEntry | None:
839
+ """Get cached ABI for a contract."""
840
+ ...
841
+
842
+ @abstractmethod
843
+ def set_cached_abi(
844
+ self,
845
+ chain_id: int,
846
+ address: str,
847
+ abi_json: str,
848
+ source: str,
849
+ ) -> None:
850
+ """Cache an ABI for a contract."""
851
+ ...
852
+
853
+ @abstractmethod
854
+ def clear_cached_abi(self, chain_id: int, address: str) -> bool:
855
+ """Clear cached ABI for a contract."""
856
+ ...
857
+
858
+ @abstractmethod
859
+ def cleanup_expired_abis(self, max_age_seconds: int) -> int:
860
+ """Delete ABIs older than max_age_seconds. Returns count deleted."""
861
+ ...
862
+
863
+ # =========================================================================
864
+ # Proxy Cache Operations
865
+ # =========================================================================
866
+
867
+ @abstractmethod
868
+ def get_cached_proxy(
869
+ self, chain_id: int, proxy_address: str
870
+ ) -> ProxyCacheEntry | None:
871
+ """Get cached proxy implementation address."""
872
+ ...
873
+
874
+ @abstractmethod
875
+ def set_cached_proxy(
876
+ self,
877
+ chain_id: int,
878
+ proxy_address: str,
879
+ implementation_address: str,
880
+ ) -> None:
881
+ """Cache a proxy implementation address."""
882
+ ...
883
+
884
+ @abstractmethod
885
+ def clear_cached_proxy(self, chain_id: int, proxy_address: str) -> bool:
886
+ """Clear cached proxy resolution."""
887
+ ...
888
+
889
+ # =========================================================================
890
+ # Cleanup & Maintenance
891
+ # =========================================================================
892
+
893
+ @abstractmethod
894
+ def cleanup_old_intents(
895
+ self,
896
+ older_than_days: int,
897
+ statuses: list[str] | None = None,
898
+ ) -> int:
899
+ """Delete old intents. Returns count deleted."""
900
+ ...
901
+
902
+ @abstractmethod
903
+ def get_database_stats(self) -> dict[str, Any]:
904
+ """Get database statistics for health checks."""
905
+ ...
906
+
907
+ # =========================================================================
908
+ # Reconciliation Operations
909
+ # =========================================================================
910
+
911
+ @abstractmethod
912
+ def clear_orphaned_claims(self, chain_id: int, older_than_minutes: int = 2) -> int:
913
+ """Clear claim fields where status != 'claimed' and claim is stale.
914
+
915
+ Only clears if claimed_at is older than threshold to avoid racing
916
+ with in-progress transitions.
917
+
918
+ Returns number of rows updated.
919
+ """
920
+ ...
921
+
922
+ @abstractmethod
923
+ def release_orphaned_nonces(self, chain_id: int, older_than_minutes: int = 5) -> int:
924
+ """Release nonces for terminal intents that are stale.
925
+
926
+ Only releases 'reserved' nonces (not 'in_flight') where:
927
+ - Intent is in terminal state (failed/abandoned/reverted)
928
+ - Intent hasn't been updated recently (avoids race with recovery)
929
+
930
+ Returns number of rows updated.
931
+ """
932
+ ...
933
+
934
+ @abstractmethod
935
+ def count_pending_without_attempts(self, chain_id: int) -> int:
936
+ """Count pending intents with no attempt records (integrity issue)."""
937
+ ...
938
+
939
+ @abstractmethod
940
+ def count_stale_claims(self, chain_id: int, older_than_minutes: int = 10) -> int:
941
+ """Count intents stuck in CLAIMED for too long."""
942
+ ...
943
+
944
+ # =========================================================================
945
+ # Invariant Queries (Phase 2)
946
+ # =========================================================================
947
+
948
+ @abstractmethod
949
+ def count_stuck_claimed(self, chain_id: int, older_than_minutes: int = 10) -> int:
950
+ """Count intents stuck in CLAIMED status for too long.
951
+
952
+ Normal claim duration is seconds to a few minutes. If an intent
953
+ has been claimed for >10 minutes, the worker likely crashed.
954
+ """
955
+ ...
956
+
957
+ @abstractmethod
958
+ def count_orphaned_claims(self, chain_id: int) -> int:
959
+ """Count intents with claim_token set but status != claimed.
960
+
961
+ Violates invariant: claim_token should only exist when claimed.
962
+ Note: Phase 1's clear_orphaned_claims repairs these; this just counts.
963
+ """
964
+ ...
965
+
966
+ @abstractmethod
967
+ def count_orphaned_nonces(self, chain_id: int) -> int:
968
+ """Count reserved/in_flight nonces for failed/abandoned intents.
969
+
970
+ These nonces are wasted and should be released.
971
+ Note: Phase 1's release_orphaned_nonces repairs these; this just counts.
972
+ """
973
+ ...
974
+
975
+ @abstractmethod
976
+ def get_oldest_nonce_gap_age_seconds(self, chain_id: int) -> float:
977
+ """Get age in seconds of the oldest nonce gap.
978
+
979
+ A "gap" is a reserved nonce below the current chain nonce that
980
+ hasn't been released. This indicates a transaction that was never
981
+ broadcast or was dropped without proper cleanup.
982
+
983
+ Returns 0 if no gaps exist OR if last_synced_chain_nonce is NULL
984
+ (stale sync state should not trigger false-positive alerts).
985
+ """
986
+ ...