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,566 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from brawny.db.sqlite import mappers, tx
9
+ from brawny.model.enums import NonceStatus
10
+ from brawny.model.errors import DatabaseError
11
+ from brawny.model.types import NonceReservation, RuntimeControl, SignerState
12
+
13
+
14
+ def get_signer_state(db: Any, chain_id: int, address: str) -> SignerState | None:
15
+ address = db._normalize_address(address)
16
+ row = db.execute_one(
17
+ "SELECT * FROM signers WHERE chain_id = ? AND signer_address = ?",
18
+ (chain_id, address),
19
+ )
20
+ if not row:
21
+ return None
22
+ return mappers._row_to_signer_state(row)
23
+
24
+
25
+ def get_all_signers(db: Any, chain_id: int) -> list[SignerState]:
26
+ rows = db.execute_returning(
27
+ "SELECT * FROM signers WHERE chain_id = ?", (chain_id,)
28
+ )
29
+ return [mappers._row_to_signer_state(row) for row in rows]
30
+
31
+
32
+ def upsert_signer(
33
+ db: Any,
34
+ chain_id: int,
35
+ address: str,
36
+ next_nonce: int,
37
+ last_synced_chain_nonce: int | None = None,
38
+ ) -> None:
39
+ address = db._normalize_address(address)
40
+ db.execute(
41
+ """
42
+ INSERT INTO signers (chain_id, signer_address, next_nonce, last_synced_chain_nonce)
43
+ VALUES (?, ?, ?, ?)
44
+ ON CONFLICT(chain_id, signer_address) DO UPDATE SET
45
+ next_nonce = excluded.next_nonce,
46
+ last_synced_chain_nonce = excluded.last_synced_chain_nonce,
47
+ updated_at = CURRENT_TIMESTAMP
48
+ """,
49
+ (chain_id, address, next_nonce, last_synced_chain_nonce),
50
+ )
51
+
52
+
53
+ def update_signer_next_nonce(db: Any, chain_id: int, address: str, next_nonce: int) -> None:
54
+ address = db._normalize_address(address)
55
+ db.execute(
56
+ """
57
+ UPDATE signers SET next_nonce = ?, updated_at = CURRENT_TIMESTAMP
58
+ WHERE chain_id = ? AND signer_address = ?
59
+ """,
60
+ (next_nonce, chain_id, address),
61
+ )
62
+
63
+
64
+ def update_signer_chain_nonce(db: Any, chain_id: int, address: str, chain_nonce: int) -> None:
65
+ address = db._normalize_address(address)
66
+ db.execute(
67
+ """
68
+ UPDATE signers SET last_synced_chain_nonce = ?, updated_at = CURRENT_TIMESTAMP
69
+ WHERE chain_id = ? AND signer_address = ?
70
+ """,
71
+ (chain_nonce, chain_id, address),
72
+ )
73
+
74
+
75
+ def set_gap_started_at(db: Any, chain_id: int, address: str, started_at: datetime) -> None:
76
+ address = db._normalize_address(address)
77
+ db.execute(
78
+ """
79
+ UPDATE signers SET gap_started_at = ?, updated_at = CURRENT_TIMESTAMP
80
+ WHERE chain_id = ? AND signer_address = ?
81
+ """,
82
+ (started_at.isoformat() if started_at else None, chain_id, address),
83
+ )
84
+
85
+
86
+ def clear_gap_started_at(db: Any, chain_id: int, address: str) -> None:
87
+ address = db._normalize_address(address)
88
+ db.execute(
89
+ """
90
+ UPDATE signers SET gap_started_at = NULL, updated_at = CURRENT_TIMESTAMP
91
+ WHERE chain_id = ? AND signer_address = ?
92
+ """,
93
+ (chain_id, address),
94
+ )
95
+
96
+
97
+ def set_signer_quarantined(
98
+ db: Any,
99
+ chain_id: int,
100
+ address: str,
101
+ reason: str,
102
+ actor: str | None = None,
103
+ source: str | None = None,
104
+ ) -> bool:
105
+ address = db._normalize_address(address)
106
+ updated = db.execute_returning_rowcount(
107
+ """
108
+ UPDATE signers
109
+ SET quarantined_at = CURRENT_TIMESTAMP,
110
+ quarantine_reason = ?,
111
+ updated_at = CURRENT_TIMESTAMP
112
+ WHERE chain_id = ? AND signer_address = ?
113
+ """,
114
+ (reason, chain_id, address),
115
+ )
116
+ if updated:
117
+ db.record_mutation_audit(
118
+ entity_type="signer",
119
+ entity_id=f"{chain_id}:{address}",
120
+ action="quarantine",
121
+ actor=actor,
122
+ reason=reason,
123
+ source=source,
124
+ )
125
+ return updated == 1
126
+
127
+
128
+ def clear_signer_quarantined(
129
+ db: Any,
130
+ chain_id: int,
131
+ address: str,
132
+ actor: str | None = None,
133
+ source: str | None = None,
134
+ ) -> bool:
135
+ address = db._normalize_address(address)
136
+ updated = db.execute_returning_rowcount(
137
+ """
138
+ UPDATE signers
139
+ SET quarantined_at = NULL,
140
+ quarantine_reason = NULL,
141
+ updated_at = CURRENT_TIMESTAMP
142
+ WHERE chain_id = ? AND signer_address = ?
143
+ """,
144
+ (chain_id, address),
145
+ )
146
+ if updated:
147
+ db.record_mutation_audit(
148
+ entity_type="signer",
149
+ entity_id=f"{chain_id}:{address}",
150
+ action="unquarantine",
151
+ actor=actor,
152
+ source=source,
153
+ )
154
+ return updated == 1
155
+
156
+
157
+ def set_replacements_paused(
158
+ db: Any,
159
+ chain_id: int,
160
+ address: str,
161
+ paused: bool,
162
+ reason: str | None = None,
163
+ actor: str | None = None,
164
+ source: str | None = None,
165
+ ) -> bool:
166
+ address = db._normalize_address(address)
167
+ updated = db.execute_returning_rowcount(
168
+ """
169
+ UPDATE signers
170
+ SET replacements_paused = ?,
171
+ updated_at = CURRENT_TIMESTAMP
172
+ WHERE chain_id = ? AND signer_address = ?
173
+ """,
174
+ (1 if paused else 0, chain_id, address),
175
+ )
176
+ if updated:
177
+ db.record_mutation_audit(
178
+ entity_type="signer",
179
+ entity_id=f"{chain_id}:{address}",
180
+ action="pause_replacements" if paused else "resume_replacements",
181
+ actor=actor,
182
+ reason=reason,
183
+ source=source,
184
+ )
185
+ return updated == 1
186
+
187
+
188
+ def set_runtime_control(
189
+ db: Any,
190
+ control: str,
191
+ active: bool,
192
+ expires_at: datetime | None,
193
+ reason: str | None,
194
+ actor: str | None,
195
+ mode: str,
196
+ ) -> RuntimeControl:
197
+ db.execute(
198
+ """
199
+ INSERT INTO runtime_controls (
200
+ control, active, expires_at, reason, actor, mode, updated_at
201
+ ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
202
+ ON CONFLICT(control) DO UPDATE SET
203
+ active = excluded.active,
204
+ expires_at = excluded.expires_at,
205
+ reason = excluded.reason,
206
+ actor = excluded.actor,
207
+ mode = excluded.mode,
208
+ updated_at = CURRENT_TIMESTAMP
209
+ """,
210
+ (
211
+ control,
212
+ 1 if active else 0,
213
+ expires_at.isoformat() if expires_at else None,
214
+ reason,
215
+ actor,
216
+ mode,
217
+ ),
218
+ )
219
+ db.record_mutation_audit(
220
+ entity_type="runtime_control",
221
+ entity_id=control,
222
+ action="activate" if active else "deactivate",
223
+ actor=actor,
224
+ reason=reason,
225
+ source="runtime_control",
226
+ metadata={"mode": mode, "expires_at": expires_at.isoformat() if expires_at else None},
227
+ )
228
+ row = db.execute_one(
229
+ "SELECT * FROM runtime_controls WHERE control = ?",
230
+ (control,),
231
+ )
232
+ if not row:
233
+ raise DatabaseError("Failed to set runtime control")
234
+ return mappers._row_to_runtime_control(row)
235
+
236
+
237
+ def get_runtime_control(db: Any, control: str) -> RuntimeControl | None:
238
+ row = db.execute_one(
239
+ "SELECT * FROM runtime_controls WHERE control = ?",
240
+ (control,),
241
+ )
242
+ if not row:
243
+ return None
244
+ return mappers._row_to_runtime_control(row)
245
+
246
+
247
+ def list_runtime_controls(db: Any) -> list[RuntimeControl]:
248
+ rows = db.execute_returning(
249
+ "SELECT * FROM runtime_controls ORDER BY control",
250
+ )
251
+ return [mappers._row_to_runtime_control(row) for row in rows]
252
+
253
+
254
+ def record_nonce_reset_audit(
255
+ db: Any,
256
+ chain_id: int,
257
+ signer_address: str,
258
+ old_next_nonce: int | None,
259
+ new_next_nonce: int,
260
+ released_reservations: int,
261
+ source: str,
262
+ reason: str | None,
263
+ ) -> None:
264
+ signer_address = db._normalize_address(signer_address)
265
+ db.execute(
266
+ """
267
+ INSERT INTO nonce_reset_audit (
268
+ chain_id, signer_address, old_next_nonce, new_next_nonce,
269
+ released_reservations, source, reason
270
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
271
+ """,
272
+ (
273
+ chain_id,
274
+ signer_address,
275
+ old_next_nonce,
276
+ new_next_nonce,
277
+ released_reservations,
278
+ source,
279
+ reason,
280
+ ),
281
+ )
282
+
283
+
284
+ def record_mutation_audit(
285
+ db: Any,
286
+ entity_type: str,
287
+ entity_id: str,
288
+ action: str,
289
+ actor: str | None = None,
290
+ reason: str | None = None,
291
+ source: str | None = None,
292
+ metadata: dict[str, Any] | None = None,
293
+ ) -> None:
294
+ metadata_json = json.dumps(metadata) if metadata else None
295
+ db.execute(
296
+ """
297
+ INSERT INTO mutation_audit (
298
+ entity_type, entity_id, action, actor, reason, source, metadata_json
299
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
300
+ """,
301
+ (entity_type, entity_id, action, actor, reason, source, metadata_json),
302
+ )
303
+
304
+
305
+ def get_signer_by_alias(db: Any, chain_id: int, alias: str) -> SignerState | None:
306
+ row = db.execute_one(
307
+ """
308
+ SELECT * FROM signers
309
+ WHERE chain_id = ? AND alias = ?
310
+ """,
311
+ (chain_id, alias),
312
+ )
313
+ if not row:
314
+ return None
315
+ return mappers._row_to_signer_state(row)
316
+
317
+
318
+ def reserve_nonce_atomic(
319
+ db: Any,
320
+ chain_id: int,
321
+ address: str,
322
+ chain_nonce: int | None,
323
+ intent_id: UUID | None = None,
324
+ ) -> int:
325
+ address = db._normalize_address(address)
326
+ intent_id_str = str(intent_id) if intent_id else None
327
+ with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
328
+ conn.execute(
329
+ """
330
+ INSERT INTO signers (chain_id, signer_address, next_nonce, last_synced_chain_nonce)
331
+ VALUES (?, ?, 0, NULL)
332
+ ON CONFLICT(chain_id, signer_address) DO NOTHING
333
+ """,
334
+ (chain_id, address),
335
+ )
336
+
337
+ cursor = conn.cursor()
338
+ try:
339
+ cursor.execute(
340
+ """
341
+ SELECT next_nonce FROM signers
342
+ WHERE chain_id = ? AND signer_address = ?
343
+ """,
344
+ (chain_id, address),
345
+ )
346
+ row = cursor.fetchone()
347
+ if row is None:
348
+ raise DatabaseError("Failed to lock signer row")
349
+
350
+ db_next_nonce = row["next_nonce"]
351
+ base_nonce = max(chain_nonce, db_next_nonce) if chain_nonce is not None else db_next_nonce
352
+
353
+ cursor.execute(
354
+ """
355
+ SELECT nonce FROM nonce_reservations
356
+ WHERE chain_id = ? AND signer_address = ?
357
+ AND status != ?
358
+ AND nonce >= ?
359
+ ORDER BY nonce
360
+ """,
361
+ (chain_id, address, NonceStatus.RELEASED.value, base_nonce),
362
+ )
363
+ rows = cursor.fetchall()
364
+ finally:
365
+ cursor.close()
366
+
367
+ candidate = base_nonce
368
+ for res in rows:
369
+ if res["nonce"] == candidate:
370
+ candidate += 1
371
+ elif res["nonce"] > candidate:
372
+ break
373
+
374
+ if candidate - base_nonce > 100:
375
+ raise DatabaseError(
376
+ f"Could not find available nonce within 100 slots for signer {address}"
377
+ )
378
+
379
+ conn.execute(
380
+ """
381
+ INSERT INTO nonce_reservations (chain_id, signer_address, nonce, status, intent_id)
382
+ VALUES (?, ?, ?, ?, ?)
383
+ ON CONFLICT(chain_id, signer_address, nonce) DO UPDATE SET
384
+ status = excluded.status,
385
+ intent_id = excluded.intent_id,
386
+ updated_at = CURRENT_TIMESTAMP
387
+ """,
388
+ (chain_id, address, candidate, NonceStatus.RESERVED.value, intent_id_str),
389
+ )
390
+
391
+ new_next_nonce = max(db_next_nonce, candidate + 1)
392
+ conn.execute(
393
+ """
394
+ UPDATE signers SET next_nonce = ?, updated_at = CURRENT_TIMESTAMP
395
+ WHERE chain_id = ? AND signer_address = ?
396
+ """,
397
+ (new_next_nonce, chain_id, address),
398
+ )
399
+ return candidate
400
+
401
+
402
+ def get_nonce_reservation(
403
+ db: Any, chain_id: int, address: str, nonce: int
404
+ ) -> NonceReservation | None:
405
+ address = db._normalize_address(address)
406
+ row = db.execute_one(
407
+ """
408
+ SELECT * FROM nonce_reservations
409
+ WHERE chain_id = ? AND signer_address = ? AND nonce = ?
410
+ """,
411
+ (chain_id, address, nonce),
412
+ )
413
+ if not row:
414
+ return None
415
+ return mappers._row_to_nonce_reservation(row)
416
+
417
+
418
+ def get_reservations_for_signer(
419
+ db: Any, chain_id: int, address: str, status: str | None = None
420
+ ) -> list[NonceReservation]:
421
+ address = db._normalize_address(address)
422
+ if status:
423
+ rows = db.execute_returning(
424
+ """
425
+ SELECT * FROM nonce_reservations
426
+ WHERE chain_id = ? AND signer_address = ? AND status = ?
427
+ ORDER BY nonce
428
+ """,
429
+ (chain_id, address, status),
430
+ )
431
+ else:
432
+ rows = db.execute_returning(
433
+ """
434
+ SELECT * FROM nonce_reservations
435
+ WHERE chain_id = ? AND signer_address = ?
436
+ ORDER BY nonce
437
+ """,
438
+ (chain_id, address),
439
+ )
440
+ return [mappers._row_to_nonce_reservation(row) for row in rows]
441
+
442
+
443
+ def get_reservations_below_nonce(
444
+ db: Any, chain_id: int, address: str, nonce: int
445
+ ) -> list[NonceReservation]:
446
+ address = db._normalize_address(address)
447
+ rows = db.execute_returning(
448
+ """
449
+ SELECT * FROM nonce_reservations
450
+ WHERE chain_id = ? AND signer_address = ? AND nonce < ?
451
+ ORDER BY nonce
452
+ """,
453
+ (chain_id, address, nonce),
454
+ )
455
+ return [mappers._row_to_nonce_reservation(row) for row in rows]
456
+
457
+
458
+ def create_nonce_reservation(
459
+ db: Any,
460
+ chain_id: int,
461
+ address: str,
462
+ nonce: int,
463
+ status: str = "reserved",
464
+ intent_id: UUID | None = None,
465
+ ) -> NonceReservation:
466
+ address = db._normalize_address(address)
467
+ intent_id_str = str(intent_id) if intent_id else None
468
+ db.execute(
469
+ """
470
+ INSERT INTO nonce_reservations (chain_id, signer_address, nonce, status, intent_id)
471
+ VALUES (?, ?, ?, ?, ?)
472
+ ON CONFLICT(chain_id, signer_address, nonce) DO UPDATE SET
473
+ status = excluded.status,
474
+ intent_id = excluded.intent_id,
475
+ updated_at = CURRENT_TIMESTAMP
476
+ """,
477
+ (chain_id, address, nonce, status, intent_id_str),
478
+ )
479
+ reservation = get_nonce_reservation(db, chain_id, address, nonce)
480
+ if not reservation:
481
+ raise DatabaseError("Failed to create nonce reservation")
482
+ return reservation
483
+
484
+
485
+ def update_nonce_reservation_status(
486
+ db: Any,
487
+ chain_id: int,
488
+ address: str,
489
+ nonce: int,
490
+ status: str,
491
+ intent_id: UUID | None = None,
492
+ ) -> bool:
493
+ address = db._normalize_address(address)
494
+ intent_id_str = str(intent_id) if intent_id else None
495
+ with tx.transaction_conn(db) as conn:
496
+ cursor = conn.cursor()
497
+ try:
498
+ if intent_id_str:
499
+ cursor.execute(
500
+ """
501
+ UPDATE nonce_reservations SET status = ?, intent_id = ?, updated_at = CURRENT_TIMESTAMP
502
+ WHERE chain_id = ? AND signer_address = ? AND nonce = ?
503
+ """,
504
+ (status, intent_id_str, chain_id, address, nonce),
505
+ )
506
+ else:
507
+ cursor.execute(
508
+ """
509
+ UPDATE nonce_reservations SET status = ?, updated_at = CURRENT_TIMESTAMP
510
+ WHERE chain_id = ? AND signer_address = ? AND nonce = ?
511
+ """,
512
+ (status, chain_id, address, nonce),
513
+ )
514
+ updated = cursor.rowcount > 0
515
+ finally:
516
+ cursor.close()
517
+ return updated
518
+
519
+
520
+ def release_nonce_reservation(
521
+ db: Any,
522
+ chain_id: int,
523
+ address: str,
524
+ nonce: int,
525
+ actor: str | None = None,
526
+ reason: str | None = None,
527
+ source: str | None = None,
528
+ ) -> bool:
529
+ address = db._normalize_address(address)
530
+ updated = update_nonce_reservation_status(db, chain_id, address, nonce, "released")
531
+ if updated:
532
+ db.execute(
533
+ """
534
+ UPDATE signers
535
+ SET next_nonce = ?, updated_at = CURRENT_TIMESTAMP
536
+ WHERE chain_id = ? AND signer_address = ? AND next_nonce = ?
537
+ """,
538
+ (nonce, chain_id, address, nonce + 1),
539
+ )
540
+ db.record_mutation_audit(
541
+ entity_type="nonce_reservation",
542
+ entity_id=f"{chain_id}:{address}:{nonce}",
543
+ action="release",
544
+ actor=actor,
545
+ reason=reason,
546
+ source=source,
547
+ )
548
+ return updated
549
+
550
+
551
+ def cleanup_orphaned_nonces(db: Any, chain_id: int, older_than_hours: int = 24) -> int:
552
+ with tx.transaction_conn(db) as conn:
553
+ cursor = conn.cursor()
554
+ try:
555
+ cursor.execute(
556
+ """
557
+ DELETE FROM nonce_reservations
558
+ WHERE chain_id = ?
559
+ AND status = 'orphaned'
560
+ AND updated_at < datetime('now', ? || ' hours')
561
+ """,
562
+ (chain_id, f"-{older_than_hours}"),
563
+ )
564
+ return cursor.rowcount
565
+ finally:
566
+ cursor.close()
brawny/db/sqlite/tx.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from contextlib import contextmanager
5
+ from enum import Enum
6
+ from typing import Any, Iterator
7
+
8
+ from brawny.db.base import IsolationLevel
9
+ from brawny.model.errors import DatabaseError, TransactionFailed
10
+
11
+
12
+ class SQLiteBeginMode(str, Enum):
13
+ DEFERRED = "DEFERRED"
14
+ IMMEDIATE = "IMMEDIATE"
15
+ EXCLUSIVE = "EXCLUSIVE"
16
+
17
+
18
+ def _resolve_begin_cmd(isolation_level: IsolationLevel | SQLiteBeginMode | None) -> str:
19
+ if isolation_level is None:
20
+ return "BEGIN"
21
+ if isinstance(isolation_level, SQLiteBeginMode):
22
+ if isolation_level is SQLiteBeginMode.DEFERRED:
23
+ return "BEGIN"
24
+ return f"BEGIN {isolation_level.value}"
25
+ if isinstance(isolation_level, str):
26
+ mapping = {
27
+ "READ UNCOMMITTED": "BEGIN",
28
+ "READ COMMITTED": "BEGIN",
29
+ "REPEATABLE READ": "BEGIN IMMEDIATE",
30
+ "SERIALIZABLE": "BEGIN EXCLUSIVE",
31
+ }
32
+ if isolation_level not in mapping:
33
+ raise DatabaseError(f"Unsupported isolation level: {isolation_level}")
34
+ begin_cmd = mapping[isolation_level]
35
+ import brawny.db.sqlite as sqlite_mod
36
+ if not getattr(sqlite_mod, "_warned_generic_isolation", False):
37
+ sqlite_mod._warned_generic_isolation = True
38
+ sqlite_mod.logger.warning(
39
+ "sqlite.isolation_level_mapped",
40
+ isolation_level=isolation_level,
41
+ begin_cmd=begin_cmd,
42
+ )
43
+ return begin_cmd
44
+ raise DatabaseError(
45
+ f"Unsupported isolation level type: {type(isolation_level).__name__}"
46
+ )
47
+
48
+
49
+ def begin_transaction(db: Any, conn: sqlite3.Connection, begin_cmd: str) -> None:
50
+ if db._tx_depth == 0:
51
+ conn.execute(begin_cmd)
52
+ db._tx_depth += 1
53
+
54
+
55
+ def commit_transaction(db: Any, conn: sqlite3.Connection) -> None:
56
+ if db._tx_depth <= 0:
57
+ raise DatabaseError("Commit requested with no active transaction")
58
+ db._tx_depth -= 1
59
+ if db._tx_depth != 0:
60
+ return
61
+ if db._tx_failed:
62
+ if conn.in_transaction:
63
+ conn.rollback()
64
+ db._tx_failed = False
65
+ raise TransactionFailed("Transaction rolled back due to earlier error")
66
+ conn.commit()
67
+
68
+
69
+ def rollback_transaction(db: Any, conn: sqlite3.Connection) -> None:
70
+ if db._tx_depth <= 0:
71
+ return
72
+ db._tx_depth -= 1
73
+ if not db._tx_failed:
74
+ db._tx_failed = True
75
+ if conn.in_transaction:
76
+ conn.rollback()
77
+ if db._tx_depth == 0:
78
+ db._tx_failed = False
79
+
80
+
81
+ @contextmanager
82
+ def transaction(
83
+ db: Any, isolation_level: IsolationLevel | SQLiteBeginMode | None = None
84
+ ) -> Iterator[None]:
85
+ """Context manager for database transactions.
86
+
87
+ SQLite uses BEGIN modes: DEFERRED, IMMEDIATE, EXCLUSIVE.
88
+ """
89
+ begin_cmd = _resolve_begin_cmd(isolation_level)
90
+
91
+ with db._locked():
92
+ conn = db._ensure_connected()
93
+ begin_transaction(db, conn, begin_cmd)
94
+ try:
95
+ yield
96
+ except Exception:
97
+ rollback_transaction(db, conn)
98
+ raise
99
+ else:
100
+ commit_transaction(db, conn)
101
+
102
+
103
+ @contextmanager
104
+ def transaction_conn(
105
+ db: Any, isolation_level: IsolationLevel | SQLiteBeginMode | None = None
106
+ ) -> Iterator[sqlite3.Connection]:
107
+ """Context manager for transaction with a connection handle."""
108
+ begin_cmd = _resolve_begin_cmd(isolation_level)
109
+
110
+ with db._locked():
111
+ conn = db._ensure_connected()
112
+ begin_transaction(db, conn, begin_cmd)
113
+ try:
114
+ yield conn
115
+ except Exception:
116
+ rollback_transaction(db, conn)
117
+ raise
118
+ else:
119
+ commit_transaction(db, conn)