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.
- brawny/__init__.py +2 -0
- brawny/_context.py +5 -5
- brawny/_rpc/__init__.py +36 -12
- brawny/_rpc/broadcast.py +14 -13
- brawny/_rpc/caller.py +243 -0
- brawny/_rpc/client.py +539 -0
- brawny/_rpc/clients.py +11 -11
- brawny/_rpc/context.py +23 -0
- brawny/_rpc/errors.py +465 -31
- brawny/_rpc/gas.py +7 -6
- brawny/_rpc/pool.py +18 -0
- brawny/_rpc/retry.py +266 -0
- brawny/_rpc/retry_policy.py +81 -0
- brawny/accounts.py +28 -9
- brawny/alerts/__init__.py +15 -18
- brawny/alerts/abi_resolver.py +212 -36
- brawny/alerts/base.py +2 -2
- brawny/alerts/contracts.py +77 -10
- brawny/alerts/errors.py +30 -3
- brawny/alerts/events.py +38 -5
- brawny/alerts/health.py +19 -13
- brawny/alerts/send.py +513 -55
- brawny/api.py +39 -11
- brawny/assets/AGENTS.md +325 -0
- brawny/async_runtime.py +48 -0
- brawny/chain.py +3 -3
- brawny/cli/commands/__init__.py +2 -0
- brawny/cli/commands/console.py +69 -19
- brawny/cli/commands/contract.py +2 -2
- brawny/cli/commands/controls.py +121 -0
- brawny/cli/commands/health.py +2 -2
- brawny/cli/commands/job_dev.py +6 -5
- brawny/cli/commands/jobs.py +99 -2
- brawny/cli/commands/maintenance.py +13 -29
- brawny/cli/commands/migrate.py +1 -0
- brawny/cli/commands/run.py +10 -3
- brawny/cli/commands/script.py +8 -3
- brawny/cli/commands/signer.py +143 -26
- brawny/cli/helpers.py +0 -3
- brawny/cli_templates.py +25 -349
- brawny/config/__init__.py +4 -1
- brawny/config/models.py +43 -57
- brawny/config/parser.py +268 -57
- brawny/config/validation.py +52 -15
- brawny/daemon/context.py +4 -2
- brawny/daemon/core.py +185 -63
- brawny/daemon/loops.py +166 -98
- brawny/daemon/supervisor.py +261 -0
- brawny/db/__init__.py +14 -26
- brawny/db/base.py +248 -151
- brawny/db/global_cache.py +11 -1
- brawny/db/migrate.py +175 -28
- brawny/db/migrations/001_init.sql +4 -3
- brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
- brawny/db/migrations/011_add_job_logs.sql +1 -2
- brawny/db/migrations/012_add_claimed_by.sql +2 -2
- brawny/db/migrations/013_attempt_unique.sql +10 -0
- brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
- brawny/db/migrations/015_add_signer_alias.sql +14 -0
- brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
- brawny/db/migrations/017_add_job_drain.sql +6 -0
- brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
- brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
- brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
- brawny/db/ops/__init__.py +3 -25
- brawny/db/ops/logs.py +1 -2
- brawny/db/queries.py +47 -91
- brawny/db/serialized.py +65 -0
- brawny/db/sqlite/__init__.py +1001 -0
- brawny/db/sqlite/connection.py +231 -0
- brawny/db/sqlite/execute.py +116 -0
- brawny/db/sqlite/mappers.py +190 -0
- brawny/db/sqlite/repos/attempts.py +372 -0
- brawny/db/sqlite/repos/block_state.py +102 -0
- brawny/db/sqlite/repos/cache.py +104 -0
- brawny/db/sqlite/repos/intents.py +1021 -0
- brawny/db/sqlite/repos/jobs.py +200 -0
- brawny/db/sqlite/repos/maintenance.py +182 -0
- brawny/db/sqlite/repos/signers_nonces.py +566 -0
- brawny/db/sqlite/tx.py +119 -0
- brawny/http.py +194 -0
- brawny/invariants.py +11 -24
- brawny/jobs/base.py +8 -0
- brawny/jobs/job_validation.py +2 -1
- brawny/keystore.py +83 -7
- brawny/lifecycle.py +64 -12
- brawny/logging.py +0 -2
- brawny/metrics.py +84 -12
- brawny/model/contexts.py +111 -9
- brawny/model/enums.py +1 -0
- brawny/model/errors.py +18 -0
- brawny/model/types.py +47 -131
- brawny/network_guard.py +133 -0
- brawny/networks/__init__.py +5 -5
- brawny/networks/config.py +1 -7
- brawny/networks/manager.py +14 -11
- brawny/runtime_controls.py +74 -0
- brawny/scheduler/poller.py +11 -7
- brawny/scheduler/reorg.py +95 -39
- brawny/scheduler/runner.py +442 -168
- brawny/scheduler/shutdown.py +3 -3
- brawny/script_tx.py +3 -3
- brawny/telegram.py +53 -7
- brawny/testing.py +1 -0
- brawny/timeout.py +38 -0
- brawny/tx/executor.py +922 -308
- brawny/tx/intent.py +54 -16
- brawny/tx/monitor.py +31 -12
- brawny/tx/nonce.py +212 -90
- brawny/tx/replacement.py +69 -18
- brawny/tx/retry_policy.py +24 -0
- brawny/tx/stages/types.py +75 -0
- brawny/types.py +18 -0
- brawny/utils.py +41 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
- brawny-0.1.22.dist-info/RECORD +163 -0
- brawny/_rpc/manager.py +0 -982
- brawny/_rpc/selector.py +0 -156
- brawny/db/base_new.py +0 -165
- brawny/db/mappers.py +0 -182
- brawny/db/migrations/008_add_transactions.sql +0 -72
- brawny/db/ops/attempts.py +0 -108
- brawny/db/ops/blocks.py +0 -83
- brawny/db/ops/cache.py +0 -93
- brawny/db/ops/intents.py +0 -296
- brawny/db/ops/jobs.py +0 -110
- brawny/db/ops/nonces.py +0 -322
- brawny/db/postgres.py +0 -2535
- brawny/db/postgres_new.py +0 -196
- brawny/db/sqlite.py +0 -2733
- brawny/db/sqlite_new.py +0 -191
- brawny-0.1.13.dist-info/RECORD +0 -141
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
- {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)
|