brawny 0.1.13__py3-none-any.whl → 0.1.22__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 (135) hide show
  1. brawny/__init__.py +2 -0
  2. brawny/_context.py +5 -5
  3. brawny/_rpc/__init__.py +36 -12
  4. brawny/_rpc/broadcast.py +14 -13
  5. brawny/_rpc/caller.py +243 -0
  6. brawny/_rpc/client.py +539 -0
  7. brawny/_rpc/clients.py +11 -11
  8. brawny/_rpc/context.py +23 -0
  9. brawny/_rpc/errors.py +465 -31
  10. brawny/_rpc/gas.py +7 -6
  11. brawny/_rpc/pool.py +18 -0
  12. brawny/_rpc/retry.py +266 -0
  13. brawny/_rpc/retry_policy.py +81 -0
  14. brawny/accounts.py +28 -9
  15. brawny/alerts/__init__.py +15 -18
  16. brawny/alerts/abi_resolver.py +212 -36
  17. brawny/alerts/base.py +2 -2
  18. brawny/alerts/contracts.py +77 -10
  19. brawny/alerts/errors.py +30 -3
  20. brawny/alerts/events.py +38 -5
  21. brawny/alerts/health.py +19 -13
  22. brawny/alerts/send.py +513 -55
  23. brawny/api.py +39 -11
  24. brawny/assets/AGENTS.md +325 -0
  25. brawny/async_runtime.py +48 -0
  26. brawny/chain.py +3 -3
  27. brawny/cli/commands/__init__.py +2 -0
  28. brawny/cli/commands/console.py +69 -19
  29. brawny/cli/commands/contract.py +2 -2
  30. brawny/cli/commands/controls.py +121 -0
  31. brawny/cli/commands/health.py +2 -2
  32. brawny/cli/commands/job_dev.py +6 -5
  33. brawny/cli/commands/jobs.py +99 -2
  34. brawny/cli/commands/maintenance.py +13 -29
  35. brawny/cli/commands/migrate.py +1 -0
  36. brawny/cli/commands/run.py +10 -3
  37. brawny/cli/commands/script.py +8 -3
  38. brawny/cli/commands/signer.py +143 -26
  39. brawny/cli/helpers.py +0 -3
  40. brawny/cli_templates.py +25 -349
  41. brawny/config/__init__.py +4 -1
  42. brawny/config/models.py +43 -57
  43. brawny/config/parser.py +268 -57
  44. brawny/config/validation.py +52 -15
  45. brawny/daemon/context.py +4 -2
  46. brawny/daemon/core.py +185 -63
  47. brawny/daemon/loops.py +166 -98
  48. brawny/daemon/supervisor.py +261 -0
  49. brawny/db/__init__.py +14 -26
  50. brawny/db/base.py +248 -151
  51. brawny/db/global_cache.py +11 -1
  52. brawny/db/migrate.py +175 -28
  53. brawny/db/migrations/001_init.sql +4 -3
  54. brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
  55. brawny/db/migrations/011_add_job_logs.sql +1 -2
  56. brawny/db/migrations/012_add_claimed_by.sql +2 -2
  57. brawny/db/migrations/013_attempt_unique.sql +10 -0
  58. brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
  59. brawny/db/migrations/015_add_signer_alias.sql +14 -0
  60. brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
  61. brawny/db/migrations/017_add_job_drain.sql +6 -0
  62. brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
  63. brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
  64. brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
  65. brawny/db/ops/__init__.py +3 -25
  66. brawny/db/ops/logs.py +1 -2
  67. brawny/db/queries.py +47 -91
  68. brawny/db/serialized.py +65 -0
  69. brawny/db/sqlite/__init__.py +1001 -0
  70. brawny/db/sqlite/connection.py +231 -0
  71. brawny/db/sqlite/execute.py +116 -0
  72. brawny/db/sqlite/mappers.py +190 -0
  73. brawny/db/sqlite/repos/attempts.py +372 -0
  74. brawny/db/sqlite/repos/block_state.py +102 -0
  75. brawny/db/sqlite/repos/cache.py +104 -0
  76. brawny/db/sqlite/repos/intents.py +1021 -0
  77. brawny/db/sqlite/repos/jobs.py +200 -0
  78. brawny/db/sqlite/repos/maintenance.py +182 -0
  79. brawny/db/sqlite/repos/signers_nonces.py +566 -0
  80. brawny/db/sqlite/tx.py +119 -0
  81. brawny/http.py +194 -0
  82. brawny/invariants.py +11 -24
  83. brawny/jobs/base.py +8 -0
  84. brawny/jobs/job_validation.py +2 -1
  85. brawny/keystore.py +83 -7
  86. brawny/lifecycle.py +64 -12
  87. brawny/logging.py +0 -2
  88. brawny/metrics.py +84 -12
  89. brawny/model/contexts.py +111 -9
  90. brawny/model/enums.py +1 -0
  91. brawny/model/errors.py +18 -0
  92. brawny/model/types.py +47 -131
  93. brawny/network_guard.py +133 -0
  94. brawny/networks/__init__.py +5 -5
  95. brawny/networks/config.py +1 -7
  96. brawny/networks/manager.py +14 -11
  97. brawny/runtime_controls.py +74 -0
  98. brawny/scheduler/poller.py +11 -7
  99. brawny/scheduler/reorg.py +95 -39
  100. brawny/scheduler/runner.py +442 -168
  101. brawny/scheduler/shutdown.py +3 -3
  102. brawny/script_tx.py +3 -3
  103. brawny/telegram.py +53 -7
  104. brawny/testing.py +1 -0
  105. brawny/timeout.py +38 -0
  106. brawny/tx/executor.py +922 -308
  107. brawny/tx/intent.py +54 -16
  108. brawny/tx/monitor.py +31 -12
  109. brawny/tx/nonce.py +212 -90
  110. brawny/tx/replacement.py +69 -18
  111. brawny/tx/retry_policy.py +24 -0
  112. brawny/tx/stages/types.py +75 -0
  113. brawny/types.py +18 -0
  114. brawny/utils.py +41 -0
  115. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
  116. brawny-0.1.22.dist-info/RECORD +163 -0
  117. brawny/_rpc/manager.py +0 -982
  118. brawny/_rpc/selector.py +0 -156
  119. brawny/db/base_new.py +0 -165
  120. brawny/db/mappers.py +0 -182
  121. brawny/db/migrations/008_add_transactions.sql +0 -72
  122. brawny/db/ops/attempts.py +0 -108
  123. brawny/db/ops/blocks.py +0 -83
  124. brawny/db/ops/cache.py +0 -93
  125. brawny/db/ops/intents.py +0 -296
  126. brawny/db/ops/jobs.py +0 -110
  127. brawny/db/ops/nonces.py +0 -322
  128. brawny/db/postgres.py +0 -2535
  129. brawny/db/postgres_new.py +0 -196
  130. brawny/db/sqlite.py +0 -2733
  131. brawny/db/sqlite_new.py +0 -191
  132. brawny-0.1.13.dist-info/RECORD +0 -141
  133. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
  134. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
  135. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1021 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import time
6
+ from datetime import datetime
7
+ from typing import Any
8
+ from uuid import UUID, uuid4
9
+
10
+ from brawny.db.sqlite import mappers, tx
11
+ from brawny.model.enums import IntentStatus
12
+ from brawny.model.errors import DatabaseError, InvariantViolation
13
+ from brawny.model.types import TxIntent
14
+ from brawny.types import ClaimedIntent
15
+
16
+
17
+ def get_inflight_intent_count(db: Any, chain_id: int, job_id: str, signer_address: str) -> int:
18
+ signer_address = db._normalize_address(signer_address)
19
+ row = db.execute_one(
20
+ """
21
+ SELECT COUNT(*) as count
22
+ FROM tx_intents
23
+ WHERE chain_id = ?
24
+ AND job_id = ?
25
+ AND signer_address = ?
26
+ AND status IN ('created', 'claimed', 'sending', 'pending')
27
+ """,
28
+ (chain_id, job_id, signer_address),
29
+ )
30
+ return int(row["count"]) if row else 0
31
+
32
+
33
+ def get_inflight_intents_for_scope(
34
+ db: Any,
35
+ chain_id: int,
36
+ job_id: str,
37
+ signer_address: str,
38
+ to_address: str,
39
+ ) -> list[dict[str, Any]]:
40
+ signer_address = db._normalize_address(signer_address)
41
+ to_address = db._normalize_address(to_address)
42
+ rows = db.execute_returning(
43
+ """
44
+ SELECT intent_id, status, claimed_at, created_at
45
+ FROM tx_intents
46
+ WHERE chain_id = ?
47
+ AND job_id = ?
48
+ AND signer_address = ?
49
+ AND to_address = ?
50
+ AND status IN ('created', 'claimed', 'sending', 'pending')
51
+ ORDER BY created_at ASC
52
+ """,
53
+ (chain_id, job_id, signer_address, to_address),
54
+ )
55
+ return [dict(row) for row in rows]
56
+
57
+
58
+ def create_intent(
59
+ db: Any,
60
+ intent_id: UUID,
61
+ job_id: str,
62
+ chain_id: int,
63
+ signer_address: str,
64
+ idempotency_key: str,
65
+ to_address: str,
66
+ data: str | None,
67
+ value_wei: str,
68
+ gas_limit: int | None,
69
+ max_fee_per_gas: str | None,
70
+ max_priority_fee_per_gas: str | None,
71
+ min_confirmations: int,
72
+ deadline_ts: datetime | None,
73
+ signer_alias: str | None = None,
74
+ broadcast_group: str | None = None,
75
+ broadcast_endpoints: list[str] | None = None,
76
+ metadata: dict | None = None,
77
+ ) -> TxIntent | None:
78
+ signer_address = db._normalize_address(signer_address)
79
+ to_address = db._normalize_address(to_address)
80
+ try:
81
+ db.execute(
82
+ """
83
+ INSERT INTO tx_intents (
84
+ intent_id, job_id, chain_id, signer_address, signer_alias, idempotency_key,
85
+ to_address, data, value_wei, gas_limit, max_fee_per_gas,
86
+ max_priority_fee_per_gas, min_confirmations, deadline_ts,
87
+ broadcast_group, broadcast_endpoints_json, retry_after, status,
88
+ metadata_json
89
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'created', ?)
90
+ """,
91
+ (
92
+ str(intent_id),
93
+ job_id,
94
+ chain_id,
95
+ signer_address,
96
+ signer_alias,
97
+ idempotency_key,
98
+ to_address,
99
+ data,
100
+ value_wei,
101
+ gas_limit,
102
+ max_fee_per_gas,
103
+ max_priority_fee_per_gas,
104
+ min_confirmations,
105
+ deadline_ts,
106
+ broadcast_group,
107
+ json.dumps(broadcast_endpoints) if broadcast_endpoints else None,
108
+ json.dumps(metadata) if metadata else None,
109
+ ),
110
+ )
111
+ return get_intent(db, intent_id)
112
+ except sqlite3.IntegrityError:
113
+ return None
114
+ except DatabaseError as e:
115
+ if "UNIQUE constraint failed" in str(e):
116
+ return None
117
+ raise
118
+
119
+
120
+ def get_intent(db: Any, intent_id: UUID) -> TxIntent | None:
121
+ row = db.execute_one(
122
+ "SELECT * FROM tx_intents WHERE intent_id = ?",
123
+ (str(intent_id),),
124
+ )
125
+ if not row:
126
+ return None
127
+ return mappers._row_to_intent(row)
128
+
129
+
130
+ def get_intent_by_idempotency_key(
131
+ db: Any,
132
+ chain_id: int,
133
+ signer_address: str,
134
+ idempotency_key: str,
135
+ ) -> TxIntent | None:
136
+ signer_address = db._normalize_address(signer_address)
137
+ row = db.execute_one(
138
+ "SELECT * FROM tx_intents WHERE chain_id = ? AND signer_address = ? AND idempotency_key = ?",
139
+ (chain_id, signer_address, idempotency_key),
140
+ )
141
+ if not row:
142
+ return None
143
+ return mappers._row_to_intent(row)
144
+
145
+
146
+ def get_intents_by_status(
147
+ db: Any,
148
+ status: str | list[str],
149
+ chain_id: int | None = None,
150
+ job_id: str | None = None,
151
+ limit: int = 100,
152
+ ) -> list[TxIntent]:
153
+ if isinstance(status, str):
154
+ status = [status]
155
+
156
+ placeholders = ",".join("?" * len(status))
157
+ query = f"SELECT * FROM tx_intents WHERE status IN ({placeholders})"
158
+ params: list[Any] = list(status)
159
+
160
+ if chain_id is not None:
161
+ query += " AND chain_id = ?"
162
+ params.append(chain_id)
163
+ if job_id is not None:
164
+ query += " AND job_id = ?"
165
+ params.append(job_id)
166
+
167
+ query += " ORDER BY created_at ASC LIMIT ?"
168
+ params.append(limit)
169
+
170
+ rows = db.execute_returning(query, tuple(params))
171
+ return [mappers._row_to_intent(row) for row in rows]
172
+
173
+
174
+ def list_intents_filtered(
175
+ db: Any,
176
+ status: str | None = None,
177
+ job_id: str | None = None,
178
+ limit: int = 50,
179
+ ) -> list[dict[str, Any]]:
180
+ query = "SELECT * FROM tx_intents WHERE 1=1"
181
+ params: list[Any] = []
182
+
183
+ if status is not None:
184
+ query += " AND status = ?"
185
+ params.append(status)
186
+ if job_id is not None:
187
+ query += " AND job_id = ?"
188
+ params.append(job_id)
189
+
190
+ query += " ORDER BY created_at DESC LIMIT ?"
191
+ params.append(limit)
192
+
193
+ return db.execute_returning(query, tuple(params))
194
+
195
+
196
+ def get_active_intent_count(db: Any, job_id: str, chain_id: int | None = None) -> int:
197
+ statuses = [
198
+ IntentStatus.CREATED.value,
199
+ IntentStatus.CLAIMED.value,
200
+ IntentStatus.SENDING.value,
201
+ IntentStatus.PENDING.value,
202
+ ]
203
+ placeholders = ",".join("?" * len(statuses))
204
+ query = (
205
+ f"SELECT COUNT(*) AS count FROM tx_intents WHERE status IN ({placeholders}) AND job_id = ?"
206
+ )
207
+ params: list[Any] = list(statuses)
208
+ params.append(job_id)
209
+ if chain_id is not None:
210
+ query += " AND chain_id = ?"
211
+ params.append(chain_id)
212
+ row = db.execute_one(query, tuple(params))
213
+ return int(row["count"]) if row else 0
214
+
215
+
216
+ def get_pending_intent_count(db: Any, chain_id: int | None = None) -> int:
217
+ statuses = [
218
+ IntentStatus.CREATED.value,
219
+ IntentStatus.CLAIMED.value,
220
+ IntentStatus.SENDING.value,
221
+ IntentStatus.PENDING.value,
222
+ ]
223
+ placeholders = ",".join("?" * len(statuses))
224
+ query = f"SELECT COUNT(*) AS count FROM tx_intents WHERE status IN ({placeholders})"
225
+ params: list[Any] = list(statuses)
226
+ if chain_id is not None:
227
+ query += " AND chain_id = ?"
228
+ params.append(chain_id)
229
+ row = db.execute_one(query, tuple(params))
230
+ return int(row["count"]) if row else 0
231
+
232
+
233
+ def get_backing_off_intent_count(db: Any, chain_id: int | None = None) -> int:
234
+ query = "SELECT COUNT(*) AS count FROM tx_intents WHERE retry_after > CURRENT_TIMESTAMP"
235
+ params: list[Any] = []
236
+ if chain_id is not None:
237
+ query += " AND chain_id = ?"
238
+ params.append(chain_id)
239
+ row = db.execute_one(query, tuple(params))
240
+ return int(row["count"]) if row else 0
241
+
242
+
243
+ def get_oldest_pending_intent_age(db: Any, chain_id: int) -> float | None:
244
+ query = """
245
+ SELECT (julianday('now') - julianday(MIN(created_at))) * 86400 AS age_seconds
246
+ FROM tx_intents
247
+ WHERE chain_id = ?
248
+ AND status IN ('created', 'pending', 'claimed', 'sending')
249
+ """
250
+ result = db.execute_one(query, (chain_id,))
251
+ if result and result.get("age_seconds") is not None:
252
+ return result["age_seconds"]
253
+ return None
254
+
255
+
256
+ def list_intent_inconsistencies(
257
+ db: Any,
258
+ max_age_seconds: int,
259
+ limit: int = 100,
260
+ chain_id: int | None = None,
261
+ ) -> list[dict[str, Any]]:
262
+ chain_clause = ""
263
+ chain_params: list[Any] = []
264
+ if chain_id is not None:
265
+ chain_clause = " AND chain_id = ?"
266
+ chain_params = [chain_id] * 5
267
+
268
+ query = f"""
269
+ SELECT intent_id, status, 'pending_no_attempt' AS reason
270
+ FROM tx_intents
271
+ WHERE status = 'pending'
272
+ {chain_clause}
273
+ AND NOT EXISTS (
274
+ SELECT 1 FROM tx_attempts
275
+ WHERE tx_attempts.intent_id = tx_intents.intent_id
276
+ AND tx_attempts.tx_hash IS NOT NULL
277
+ )
278
+
279
+ UNION ALL
280
+ SELECT intent_id, status, 'confirmed_no_confirmed_attempt' AS reason
281
+ FROM tx_intents
282
+ WHERE status = 'confirmed'
283
+ {chain_clause}
284
+ AND NOT EXISTS (
285
+ SELECT 1 FROM tx_attempts
286
+ WHERE tx_attempts.intent_id = tx_intents.intent_id
287
+ AND tx_attempts.status = 'confirmed'
288
+ )
289
+
290
+ UNION ALL
291
+ SELECT intent_id, status, 'claimed_missing_claim' AS reason
292
+ FROM tx_intents
293
+ WHERE status = 'claimed'
294
+ {chain_clause}
295
+ AND (claim_token IS NULL OR claimed_at IS NULL OR lease_expires_at IS NULL)
296
+
297
+ UNION ALL
298
+ SELECT intent_id, status, 'nonclaimed_with_claim' AS reason
299
+ FROM tx_intents
300
+ WHERE status != 'claimed'
301
+ {chain_clause}
302
+ AND (claim_token IS NOT NULL OR claimed_at IS NOT NULL OR lease_expires_at IS NOT NULL)
303
+
304
+ UNION ALL
305
+ SELECT intent_id, status, 'sending_stuck' AS reason
306
+ FROM tx_intents
307
+ WHERE status = 'sending'
308
+ {chain_clause}
309
+ AND updated_at < datetime('now', ? || ' seconds')
310
+
311
+ LIMIT ?
312
+ """
313
+ params_with_age = chain_params + [f"-{max_age_seconds}", limit]
314
+ rows = db.execute_returning(query, tuple(params_with_age))
315
+ return [dict(row) for row in rows]
316
+
317
+
318
+ def list_sending_intents_older_than(
319
+ db: Any,
320
+ max_age_seconds: int,
321
+ limit: int = 100,
322
+ chain_id: int | None = None,
323
+ ) -> list[TxIntent]:
324
+ query = """
325
+ SELECT * FROM tx_intents
326
+ WHERE status = 'sending'
327
+ AND updated_at < datetime('now', ? || ' seconds')
328
+ """
329
+ params: list[Any] = [f"-{max_age_seconds}"]
330
+ if chain_id is not None:
331
+ query += " AND chain_id = ?"
332
+ params.append(chain_id)
333
+ query += " ORDER BY updated_at ASC LIMIT ?"
334
+ params.append(limit)
335
+ rows = db.execute_returning(query, tuple(params))
336
+ return [mappers._row_to_intent(row) for row in rows]
337
+
338
+
339
+ def list_claimed_intents_older_than(
340
+ db: Any,
341
+ max_age_seconds: int,
342
+ limit: int = 100,
343
+ chain_id: int | None = None,
344
+ ) -> list[TxIntent]:
345
+ query = """
346
+ SELECT * FROM tx_intents
347
+ WHERE status = 'claimed'
348
+ AND COALESCE(lease_expires_at, datetime(claimed_at, ? || ' seconds')) < CURRENT_TIMESTAMP
349
+ AND EXISTS (
350
+ SELECT 1 FROM tx_attempts WHERE tx_attempts.intent_id = tx_intents.intent_id
351
+ )
352
+ """
353
+ params: list[Any] = [f"+{max_age_seconds}"]
354
+ if chain_id is not None:
355
+ query += " AND chain_id = ?"
356
+ params.append(chain_id)
357
+ query += " ORDER BY updated_at ASC LIMIT ?"
358
+ params.append(limit)
359
+ rows = db.execute_returning(query, tuple(params))
360
+ return [mappers._row_to_intent(row) for row in rows]
361
+
362
+
363
+ def claim_next_intent(
364
+ db: Any,
365
+ claim_token: str,
366
+ claimed_by: str | None = None,
367
+ lease_seconds: int | None = None,
368
+ ) -> ClaimedIntent | None:
369
+ if lease_seconds is None:
370
+ raise DatabaseError("lease_seconds is required for claim_next_intent")
371
+ if lease_seconds <= 0:
372
+ raise DatabaseError("lease_seconds must be positive for claim_next_intent")
373
+ with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
374
+ cursor = conn.cursor()
375
+ try:
376
+ cursor.execute(
377
+ """
378
+ UPDATE tx_intents
379
+ SET status = 'claimed', claim_token = ?, claimed_at = CURRENT_TIMESTAMP,
380
+ claimed_by = ?,
381
+ lease_expires_at = datetime('now', ? || ' seconds'),
382
+ retry_after = NULL,
383
+ updated_at = CURRENT_TIMESTAMP
384
+ WHERE intent_id = (
385
+ SELECT intent_id FROM tx_intents
386
+ WHERE status = 'created'
387
+ AND (deadline_ts IS NULL OR deadline_ts > CURRENT_TIMESTAMP)
388
+ AND (retry_after IS NULL OR retry_after <= CURRENT_TIMESTAMP)
389
+ ORDER BY created_at ASC, intent_id ASC
390
+ LIMIT 1
391
+ )
392
+ AND status = 'created'
393
+ """,
394
+ (claim_token, claimed_by, f"{int(lease_seconds)}"),
395
+ )
396
+
397
+ if cursor.rowcount == 0:
398
+ return None
399
+
400
+ cursor.execute(
401
+ "SELECT * FROM tx_intents WHERE claim_token = ? AND status = 'claimed'",
402
+ (claim_token,),
403
+ )
404
+ row = cursor.fetchone()
405
+ if row:
406
+ return mappers._row_to_claimed_intent(dict(row))
407
+ return None
408
+ finally:
409
+ cursor.close()
410
+
411
+
412
+ def update_intent_status(
413
+ db: Any,
414
+ intent_id: UUID,
415
+ status: str,
416
+ claim_token: str | None = None,
417
+ ) -> bool:
418
+ with tx.transaction_conn(db) as conn:
419
+ cursor = conn.cursor()
420
+ try:
421
+ if claim_token:
422
+ cursor.execute(
423
+ """
424
+ UPDATE tx_intents SET status = ?, claim_token = ?,
425
+ claimed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
426
+ WHERE intent_id = ?
427
+ """,
428
+ (status, claim_token, str(intent_id)),
429
+ )
430
+ else:
431
+ cursor.execute(
432
+ """
433
+ UPDATE tx_intents SET status = ?, updated_at = CURRENT_TIMESTAMP
434
+ WHERE intent_id = ?
435
+ """,
436
+ (status, str(intent_id)),
437
+ )
438
+ return cursor.rowcount > 0
439
+ finally:
440
+ cursor.close()
441
+
442
+
443
+ def update_intent_status_if(
444
+ db: Any,
445
+ intent_id: UUID,
446
+ status: str,
447
+ expected_status: str | list[str],
448
+ ) -> bool:
449
+ if isinstance(expected_status, str):
450
+ expected_status = [expected_status]
451
+ placeholders = ",".join("?" * len(expected_status))
452
+ with tx.transaction_conn(db) as conn:
453
+ cursor = conn.cursor()
454
+ try:
455
+ cursor.execute(
456
+ f"""
457
+ UPDATE tx_intents SET status = ?, updated_at = CURRENT_TIMESTAMP
458
+ WHERE intent_id = ? AND status IN ({placeholders})
459
+ """,
460
+ (status, str(intent_id), *expected_status),
461
+ )
462
+ return cursor.rowcount > 0
463
+ finally:
464
+ cursor.close()
465
+
466
+
467
+ def transition_intent_status_immediate(
468
+ db: Any,
469
+ intent_id: UUID,
470
+ from_statuses: list[str],
471
+ to_status: str,
472
+ ) -> tuple[bool, str | None]:
473
+ placeholders = ",".join("?" * len(from_statuses))
474
+ with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
475
+ cursor = conn.cursor()
476
+ try:
477
+ cursor.execute(
478
+ "SELECT status FROM tx_intents WHERE intent_id = ?",
479
+ (str(intent_id),),
480
+ )
481
+ row = cursor.fetchone()
482
+ if not row:
483
+ return (False, None)
484
+
485
+ old_status = row[0]
486
+
487
+ if old_status not in from_statuses:
488
+ return (False, None)
489
+
490
+ should_clear_claim = old_status == "claimed" and to_status != "claimed"
491
+
492
+ if should_clear_claim:
493
+ cursor.execute(
494
+ f"""
495
+ UPDATE tx_intents
496
+ SET status = ?,
497
+ updated_at = CURRENT_TIMESTAMP,
498
+ claim_token = NULL,
499
+ claimed_at = NULL,
500
+ claimed_by = NULL,
501
+ lease_expires_at = NULL
502
+ WHERE intent_id = ? AND status IN ({placeholders})
503
+ """,
504
+ (to_status, str(intent_id), *from_statuses),
505
+ )
506
+ else:
507
+ cursor.execute(
508
+ f"""
509
+ UPDATE tx_intents
510
+ SET status = ?, updated_at = CURRENT_TIMESTAMP
511
+ WHERE intent_id = ? AND status IN ({placeholders})
512
+ """,
513
+ (to_status, str(intent_id), *from_statuses),
514
+ )
515
+
516
+ if cursor.rowcount == 0:
517
+ return (False, None)
518
+
519
+ return (True, old_status)
520
+ finally:
521
+ cursor.close()
522
+
523
+
524
+ def update_intent_signer(db: Any, intent_id: UUID, signer_address: str) -> bool:
525
+ signer_address = db._normalize_address(signer_address)
526
+ with tx.transaction_conn(db) as conn:
527
+ cursor = conn.cursor()
528
+ try:
529
+ cursor.execute(
530
+ """
531
+ UPDATE tx_intents SET signer_address = ?, updated_at = CURRENT_TIMESTAMP
532
+ WHERE intent_id = ?
533
+ """,
534
+ (signer_address, str(intent_id)),
535
+ )
536
+ return cursor.rowcount > 0
537
+ finally:
538
+ cursor.close()
539
+
540
+
541
+ def release_intent_claim(db: Any, intent_id: UUID) -> bool:
542
+ with tx.transaction_conn(db) as conn:
543
+ cursor = conn.cursor()
544
+ try:
545
+ cursor.execute(
546
+ """
547
+ UPDATE tx_intents SET status = 'created', claim_token = NULL,
548
+ claimed_at = NULL, lease_expires_at = NULL, updated_at = CURRENT_TIMESTAMP
549
+ WHERE intent_id = ? AND status = 'claimed'
550
+ """,
551
+ (str(intent_id),),
552
+ )
553
+ return cursor.rowcount > 0
554
+ finally:
555
+ cursor.close()
556
+
557
+
558
+ def release_intent_claim_if_token(db: Any, intent_id: UUID, claim_token: str) -> bool:
559
+ rowcount = db.execute_returning_rowcount(
560
+ """
561
+ UPDATE tx_intents
562
+ SET status = 'created',
563
+ claim_token = NULL,
564
+ claimed_at = NULL,
565
+ claimed_by = NULL,
566
+ lease_expires_at = NULL,
567
+ updated_at = CURRENT_TIMESTAMP
568
+ WHERE intent_id = ? AND claim_token = ? AND status = 'claimed'
569
+ """,
570
+ (str(intent_id), claim_token),
571
+ )
572
+ return rowcount == 1
573
+
574
+
575
+ def release_claim_if_token_and_no_attempts(db: Any, intent_id: UUID, claim_token: str) -> bool:
576
+ rowcount = db.execute_returning_rowcount(
577
+ """
578
+ UPDATE tx_intents
579
+ SET status = 'created',
580
+ claim_token = NULL,
581
+ claimed_at = NULL,
582
+ claimed_by = NULL,
583
+ lease_expires_at = NULL,
584
+ updated_at = CURRENT_TIMESTAMP
585
+ WHERE intent_id = ?
586
+ AND claim_token = ?
587
+ AND status = 'claimed'
588
+ AND NOT EXISTS (
589
+ SELECT 1 FROM tx_attempts
590
+ WHERE tx_attempts.intent_id = tx_intents.intent_id
591
+ )
592
+ """,
593
+ (str(intent_id), claim_token),
594
+ )
595
+ return rowcount == 1
596
+
597
+
598
+ def clear_intent_claim(db: Any, intent_id: UUID) -> bool:
599
+ with tx.transaction_conn(db) as conn:
600
+ cursor = conn.cursor()
601
+ try:
602
+ cursor.execute(
603
+ """
604
+ UPDATE tx_intents
605
+ SET claim_token = NULL, claimed_at = NULL, lease_expires_at = NULL,
606
+ updated_at = CURRENT_TIMESTAMP
607
+ WHERE intent_id = ?
608
+ """,
609
+ (str(intent_id),),
610
+ )
611
+ return cursor.rowcount > 0
612
+ finally:
613
+ cursor.close()
614
+
615
+
616
+ def set_intent_retry_after(db: Any, intent_id: UUID, retry_after: datetime | None) -> bool:
617
+ with tx.transaction_conn(db) as conn:
618
+ cursor = conn.cursor()
619
+ try:
620
+ cursor.execute(
621
+ """
622
+ UPDATE tx_intents
623
+ SET retry_after = ?, updated_at = CURRENT_TIMESTAMP
624
+ WHERE intent_id = ?
625
+ """,
626
+ (retry_after, str(intent_id)),
627
+ )
628
+ return cursor.rowcount > 0
629
+ finally:
630
+ cursor.close()
631
+
632
+
633
+ def increment_intent_retry_count(db: Any, intent_id: UUID) -> int:
634
+ with tx.transaction_conn(db) as conn:
635
+ cursor = conn.cursor()
636
+ try:
637
+ cursor.execute(
638
+ """
639
+ UPDATE tx_intents
640
+ SET retry_count = retry_count + 1, updated_at = CURRENT_TIMESTAMP
641
+ WHERE intent_id = ?
642
+ """,
643
+ (str(intent_id),),
644
+ )
645
+ if cursor.rowcount == 0:
646
+ return 0
647
+ cursor.execute(
648
+ "SELECT retry_count FROM tx_intents WHERE intent_id = ?",
649
+ (str(intent_id),),
650
+ )
651
+ row = cursor.fetchone()
652
+ return row[0] if row else 0
653
+ finally:
654
+ cursor.close()
655
+
656
+
657
+ def should_create_intent(
658
+ db: Any,
659
+ cooldown_key: str,
660
+ now: int,
661
+ cooldown_seconds: int,
662
+ ) -> tuple[bool, int | None]:
663
+ with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
664
+ cursor = conn.cursor()
665
+ try:
666
+ cursor.execute(
667
+ "SELECT last_intent_at FROM job_cooldowns WHERE cooldown_key = ?",
668
+ (cooldown_key,),
669
+ )
670
+ row = cursor.fetchone()
671
+ if row is None:
672
+ cursor.execute(
673
+ "INSERT INTO job_cooldowns (cooldown_key, last_intent_at) VALUES (?, ?)",
674
+ (cooldown_key, now),
675
+ )
676
+ return True, None
677
+
678
+ last_intent_at = int(row[0])
679
+ if now - last_intent_at >= cooldown_seconds:
680
+ cursor.execute(
681
+ "UPDATE job_cooldowns SET last_intent_at = ? WHERE cooldown_key = ?",
682
+ (now, cooldown_key),
683
+ )
684
+ return True, last_intent_at
685
+
686
+ return False, last_intent_at
687
+ finally:
688
+ cursor.close()
689
+
690
+
691
+ def prune_job_cooldowns(db: Any, older_than_days: int) -> int:
692
+ if older_than_days <= 0:
693
+ return 0
694
+ cutoff = int(time.time()) - (older_than_days * 86400)
695
+ rowcount = db.execute_returning_rowcount(
696
+ "DELETE FROM job_cooldowns WHERE last_intent_at < ?",
697
+ (cutoff,),
698
+ )
699
+ return rowcount
700
+
701
+
702
+ def requeue_expired_claims_no_attempts(
703
+ db: Any,
704
+ limit: int,
705
+ grace_seconds: int,
706
+ chain_id: int | None = None,
707
+ ) -> int:
708
+ if limit <= 0:
709
+ return 0
710
+ offset = f"-{grace_seconds} seconds"
711
+ params: list[Any] = [offset]
712
+ chain_clause = ""
713
+ if chain_id is not None:
714
+ chain_clause = "AND chain_id = ?"
715
+ params.append(chain_id)
716
+ params.append(limit)
717
+
718
+ with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
719
+ cursor = conn.cursor()
720
+ try:
721
+ cursor.execute(
722
+ f"""
723
+ WITH expired AS (
724
+ SELECT intent_id
725
+ FROM tx_intents
726
+ WHERE status = 'claimed'
727
+ AND lease_expires_at IS NOT NULL
728
+ AND lease_expires_at < datetime('now', ?)
729
+ {chain_clause}
730
+ AND NOT EXISTS (
731
+ SELECT 1 FROM tx_attempts
732
+ WHERE tx_attempts.intent_id = tx_intents.intent_id
733
+ )
734
+ ORDER BY lease_expires_at ASC, intent_id ASC
735
+ LIMIT ?
736
+ )
737
+ UPDATE tx_intents
738
+ SET status = 'created',
739
+ claim_token = NULL,
740
+ claimed_at = NULL,
741
+ claimed_by = NULL,
742
+ lease_expires_at = NULL,
743
+ updated_at = CURRENT_TIMESTAMP
744
+ WHERE intent_id IN (SELECT intent_id FROM expired)
745
+ AND status = 'claimed'
746
+ AND NOT EXISTS (
747
+ SELECT 1 FROM tx_attempts
748
+ WHERE tx_attempts.intent_id = tx_intents.intent_id
749
+ )
750
+ """,
751
+ tuple(params),
752
+ )
753
+ cursor.execute("SELECT changes()")
754
+ row = cursor.fetchone()
755
+ return row[0] if row else 0
756
+ finally:
757
+ cursor.close()
758
+
759
+
760
+ def count_expired_claims_with_attempts(
761
+ db: Any,
762
+ limit: int,
763
+ grace_seconds: int,
764
+ chain_id: int | None = None,
765
+ ) -> int:
766
+ if limit <= 0:
767
+ return 0
768
+ offset = f"-{grace_seconds} seconds"
769
+ params: list[Any] = [offset]
770
+ chain_clause = ""
771
+ if chain_id is not None:
772
+ chain_clause = "AND chain_id = ?"
773
+ params.append(chain_id)
774
+ params.append(limit)
775
+ row = db.execute_one(
776
+ f"""
777
+ SELECT COUNT(*) AS count FROM (
778
+ SELECT intent_id
779
+ FROM tx_intents
780
+ WHERE status = 'claimed'
781
+ AND lease_expires_at IS NOT NULL
782
+ AND lease_expires_at < datetime('now', ?)
783
+ {chain_clause}
784
+ AND EXISTS (
785
+ SELECT 1 FROM tx_attempts
786
+ WHERE tx_attempts.intent_id = tx_intents.intent_id
787
+ )
788
+ ORDER BY lease_expires_at ASC, intent_id ASC
789
+ LIMIT ?
790
+ )
791
+ """,
792
+ tuple(params),
793
+ )
794
+ return int(row["count"]) if row else 0
795
+
796
+
797
+ def requeue_missing_lease_claims_no_attempts(
798
+ db: Any,
799
+ limit: int,
800
+ cutoff_seconds: int,
801
+ chain_id: int | None = None,
802
+ ) -> int:
803
+ if limit <= 0:
804
+ return 0
805
+ offset = f"-{cutoff_seconds} seconds"
806
+ params: list[Any] = [offset]
807
+ chain_clause = ""
808
+ if chain_id is not None:
809
+ chain_clause = "AND chain_id = ?"
810
+ params.append(chain_id)
811
+ params.append(limit)
812
+
813
+ with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
814
+ cursor = conn.cursor()
815
+ try:
816
+ cursor.execute(
817
+ f"""
818
+ WITH stale AS (
819
+ SELECT intent_id
820
+ FROM tx_intents
821
+ WHERE status = 'claimed'
822
+ AND lease_expires_at IS NULL
823
+ AND claimed_at IS NOT NULL
824
+ AND claimed_at < datetime('now', ?)
825
+ {chain_clause}
826
+ AND NOT EXISTS (
827
+ SELECT 1 FROM tx_attempts
828
+ WHERE tx_attempts.intent_id = tx_intents.intent_id
829
+ )
830
+ ORDER BY claimed_at ASC, intent_id ASC
831
+ LIMIT ?
832
+ )
833
+ UPDATE tx_intents
834
+ SET status = 'created',
835
+ claim_token = NULL,
836
+ claimed_at = NULL,
837
+ claimed_by = NULL,
838
+ lease_expires_at = NULL,
839
+ updated_at = CURRENT_TIMESTAMP
840
+ WHERE intent_id IN (SELECT intent_id FROM stale)
841
+ AND status = 'claimed'
842
+ AND lease_expires_at IS NULL
843
+ AND NOT EXISTS (
844
+ SELECT 1 FROM tx_attempts
845
+ WHERE tx_attempts.intent_id = tx_intents.intent_id
846
+ )
847
+ """,
848
+ tuple(params),
849
+ )
850
+ cursor.execute("SELECT changes()")
851
+ row = cursor.fetchone()
852
+ return row[0] if row else 0
853
+ finally:
854
+ cursor.close()
855
+
856
+
857
+ def count_missing_lease_claims_with_attempts(
858
+ db: Any,
859
+ limit: int,
860
+ cutoff_seconds: int,
861
+ chain_id: int | None = None,
862
+ ) -> int:
863
+ if limit <= 0:
864
+ return 0
865
+ offset = f"-{cutoff_seconds} seconds"
866
+ params: list[Any] = [offset]
867
+ chain_clause = ""
868
+ if chain_id is not None:
869
+ chain_clause = "AND chain_id = ?"
870
+ params.append(chain_id)
871
+ params.append(limit)
872
+ row = db.execute_one(
873
+ f"""
874
+ SELECT COUNT(*) AS count FROM (
875
+ SELECT intent_id
876
+ FROM tx_intents
877
+ WHERE status = 'claimed'
878
+ AND lease_expires_at IS NULL
879
+ AND claimed_at IS NOT NULL
880
+ AND claimed_at < datetime('now', ?)
881
+ {chain_clause}
882
+ AND EXISTS (
883
+ SELECT 1 FROM tx_attempts
884
+ WHERE tx_attempts.intent_id = tx_intents.intent_id
885
+ )
886
+ ORDER BY claimed_at ASC, intent_id ASC
887
+ LIMIT ?
888
+ )
889
+ """,
890
+ tuple(params),
891
+ )
892
+ return int(row["count"]) if row else 0
893
+
894
+
895
+ def abandon_intent(db: Any, intent_id: UUID) -> bool:
896
+ return update_intent_status(db, intent_id, "abandoned")
897
+
898
+
899
+ def get_pending_intents_for_signer(db: Any, chain_id: int, address: str) -> list[TxIntent]:
900
+ address = db._normalize_address(address)
901
+ rows = db.execute_returning(
902
+ """
903
+ SELECT * FROM tx_intents
904
+ WHERE chain_id = ? AND signer_address = ?
905
+ AND status IN ('sending', 'pending')
906
+ ORDER BY created_at
907
+ """,
908
+ (chain_id, address),
909
+ )
910
+ return [mappers._row_to_intent(row) for row in rows]
911
+
912
+
913
+ def bind_broadcast_endpoints(
914
+ db: Any,
915
+ intent_id: UUID,
916
+ group_name: str | None,
917
+ endpoints: list[str],
918
+ ) -> tuple[str | None, list[str]]:
919
+ canonical = db._canonicalize_endpoints(endpoints)
920
+ if not canonical:
921
+ raise DatabaseError("Broadcast endpoints list is empty after canonicalization")
922
+ with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
923
+ cursor = conn.cursor()
924
+ try:
925
+ cursor.execute(
926
+ """
927
+ SELECT broadcast_group, broadcast_endpoints_json
928
+ FROM tx_intents
929
+ WHERE intent_id = ?
930
+ """,
931
+ (str(intent_id),),
932
+ )
933
+ row = cursor.fetchone()
934
+ if not row:
935
+ raise DatabaseError(f"Intent {intent_id} not found")
936
+
937
+ if row["broadcast_endpoints_json"] is not None:
938
+ stored = json.loads(row["broadcast_endpoints_json"])
939
+ stored_canonical = db._canonicalize_endpoints(stored)
940
+ if stored_canonical == canonical and (
941
+ group_name is None or row["broadcast_group"] == group_name
942
+ ):
943
+ return row["broadcast_group"], stored_canonical
944
+ raise InvariantViolation(
945
+ f"Intent {intent_id} already bound with different endpoints"
946
+ )
947
+
948
+ binding_id = str(uuid4())
949
+ cursor.execute(
950
+ """
951
+ UPDATE tx_intents
952
+ SET broadcast_group = ?,
953
+ broadcast_endpoints_json = ?,
954
+ broadcast_binding_id = ?,
955
+ updated_at = CURRENT_TIMESTAMP
956
+ WHERE intent_id = ?
957
+ AND broadcast_endpoints_json IS NULL
958
+ """,
959
+ (group_name, json.dumps(canonical), binding_id, str(intent_id)),
960
+ )
961
+
962
+ if cursor.rowcount != 1:
963
+ cursor.execute(
964
+ """
965
+ SELECT broadcast_group, broadcast_endpoints_json
966
+ FROM tx_intents
967
+ WHERE intent_id = ?
968
+ """,
969
+ (str(intent_id),),
970
+ )
971
+ row = cursor.fetchone()
972
+ if not row or row["broadcast_endpoints_json"] is None:
973
+ raise InvariantViolation(
974
+ f"Binding race for intent {intent_id}: no binding persisted"
975
+ )
976
+ stored = json.loads(row["broadcast_endpoints_json"])
977
+ stored_canonical = db._canonicalize_endpoints(stored)
978
+ if stored_canonical == canonical and (
979
+ group_name is None or row["broadcast_group"] == group_name
980
+ ):
981
+ return row["broadcast_group"], stored_canonical
982
+ raise InvariantViolation(
983
+ f"Intent {intent_id} already bound with different endpoints"
984
+ )
985
+
986
+ return group_name, canonical
987
+ finally:
988
+ cursor.close()
989
+
990
+
991
+ def get_broadcast_binding(db: Any, intent_id: UUID) -> tuple[str | None, list[str]] | None:
992
+ row = db.execute_one(
993
+ """
994
+ SELECT broadcast_group, broadcast_endpoints_json
995
+ FROM tx_intents
996
+ WHERE intent_id = ?
997
+ """,
998
+ (str(intent_id),),
999
+ )
1000
+
1001
+ if not row:
1002
+ return None
1003
+
1004
+ has_endpoints = row["broadcast_endpoints_json"] is not None
1005
+ if not has_endpoints:
1006
+ return None
1007
+
1008
+ endpoints = json.loads(row["broadcast_endpoints_json"])
1009
+ if not isinstance(endpoints, list):
1010
+ raise ValueError(
1011
+ f"Corrupt binding for intent {intent_id}: "
1012
+ f"endpoints_json is {type(endpoints).__name__}, expected list"
1013
+ )
1014
+
1015
+ canonical = db._canonicalize_endpoints(endpoints)
1016
+ if not canonical:
1017
+ raise ValueError(
1018
+ f"Corrupt binding for intent {intent_id}: endpoints list is empty"
1019
+ )
1020
+
1021
+ return row["broadcast_group"], canonical