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/tx/fees.py ADDED
@@ -0,0 +1,50 @@
1
+ """Fee calculation helpers for transaction management.
2
+
3
+ Provides shared fee bumping logic used by executor and replacement modules.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from brawny.model.types import GasParams
9
+
10
+
11
+ def bump_fees(
12
+ old_params: GasParams,
13
+ bump_percent: float,
14
+ max_fee_cap: int | None = None,
15
+ ) -> GasParams:
16
+ """Calculate bumped gas fees for replacement transactions.
17
+
18
+ Per Ethereum protocol, replacement must have at least 10% higher fees.
19
+ This function applies the configured bump percentage and enforces
20
+ maximum fee caps on both max_fee and priority_fee.
21
+
22
+ Args:
23
+ old_params: Previous gas parameters
24
+ bump_percent: Percentage to bump fees (e.g., 15 for 15%)
25
+ max_fee_cap: Optional max fee cap in wei. If set, both
26
+ max_fee_per_gas and max_priority_fee_per_gas
27
+ will not exceed this value.
28
+
29
+ Returns:
30
+ New GasParams with bumped fees
31
+ """
32
+ # Use integer arithmetic to avoid float precision issues with large values
33
+ bump_numerator = 100 + int(bump_percent)
34
+
35
+ new_max_fee = (old_params.max_fee_per_gas * bump_numerator) // 100
36
+ new_priority_fee = (old_params.max_priority_fee_per_gas * bump_numerator) // 100
37
+
38
+ # Enforce max fee cap on BOTH fees if specified (already in wei)
39
+ if max_fee_cap is not None:
40
+ new_max_fee = min(new_max_fee, max_fee_cap)
41
+ new_priority_fee = min(new_priority_fee, max_fee_cap)
42
+
43
+ # Ensure priority fee never exceeds max fee (protocol requirement)
44
+ new_priority_fee = min(new_priority_fee, new_max_fee)
45
+
46
+ return GasParams(
47
+ gas_limit=old_params.gas_limit, # Keep same
48
+ max_fee_per_gas=new_max_fee,
49
+ max_priority_fee_per_gas=new_priority_fee,
50
+ )
brawny/tx/intent.py ADDED
@@ -0,0 +1,423 @@
1
+ """Transaction intent creation and management.
2
+
3
+ Implements durable intent model from SPEC 6:
4
+ - Idempotency via unique key constraint
5
+ - Create-or-get semantics for deduplication
6
+ - Intents are persisted BEFORE signing/sending
7
+
8
+ Golden Rule: Persist intent before signing/sending - this is non-negotiable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import TYPE_CHECKING
15
+ from uuid import UUID, uuid4
16
+
17
+ from brawny.logging import LogEvents, get_logger
18
+ from brawny.metrics import INTENT_TRANSITIONS, get_metrics
19
+ from brawny.model.enums import IntentStatus
20
+ from brawny.model.types import TxIntent, TxIntentSpec, Trigger, idempotency_key
21
+
22
+ if TYPE_CHECKING:
23
+ from brawny.db.base import Database
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ ALLOWED_TRANSITIONS: dict[str, set[str]] = {
28
+ IntentStatus.CREATED.value: {IntentStatus.CLAIMED.value, IntentStatus.SENDING.value},
29
+ IntentStatus.CLAIMED.value: {
30
+ IntentStatus.SENDING.value,
31
+ IntentStatus.CREATED.value,
32
+ IntentStatus.FAILED.value,
33
+ IntentStatus.ABANDONED.value,
34
+ },
35
+ IntentStatus.SENDING.value: {
36
+ IntentStatus.PENDING.value,
37
+ IntentStatus.CREATED.value,
38
+ IntentStatus.FAILED.value,
39
+ IntentStatus.ABANDONED.value,
40
+ },
41
+ IntentStatus.PENDING.value: {
42
+ IntentStatus.CONFIRMED.value,
43
+ IntentStatus.FAILED.value,
44
+ IntentStatus.ABANDONED.value,
45
+ },
46
+ IntentStatus.CONFIRMED.value: {IntentStatus.PENDING.value}, # reorg
47
+ IntentStatus.FAILED.value: set(), # terminal
48
+ IntentStatus.ABANDONED.value: set(), # terminal
49
+ }
50
+
51
+
52
+ def create_intent(
53
+ db: Database,
54
+ job_id: str,
55
+ chain_id: int,
56
+ spec: TxIntentSpec,
57
+ idem_parts: list[str | int | bytes],
58
+ broadcast_group: str | None = None,
59
+ broadcast_endpoints: list[str] | None = None,
60
+ trigger: Trigger | None = None,
61
+ ) -> tuple[TxIntent, bool]:
62
+ """Create a new transaction intent with idempotency.
63
+
64
+ Implements create-or-get semantics:
65
+ - If intent with same idempotency key exists, return it
66
+ - Otherwise create new intent
67
+
68
+ Args:
69
+ db: Database connection
70
+ job_id: Job that triggered this intent
71
+ chain_id: Chain ID for the transaction
72
+ spec: Transaction specification
73
+ idem_parts: Parts to include in idempotency key
74
+ trigger: Trigger that caused this intent (for metadata auto-merge)
75
+
76
+ Returns:
77
+ Tuple of (intent, is_new) where is_new is True if newly created
78
+ """
79
+ # Generate idempotency key from job_id and parts
80
+ idem_key = idempotency_key(job_id, *idem_parts)
81
+
82
+ # Check for existing intent (scoped to chain + signer)
83
+ existing = db.get_intent_by_idempotency_key(
84
+ chain_id=chain_id,
85
+ signer_address=spec.signer_address.lower(),
86
+ idempotency_key=idem_key,
87
+ )
88
+ if existing:
89
+ logger.info(
90
+ LogEvents.INTENT_DEDUPE,
91
+ job_id=job_id,
92
+ idempotency_key=idem_key,
93
+ chain_id=chain_id,
94
+ signer=spec.signer_address.lower(),
95
+ existing_intent_id=str(existing.intent_id),
96
+ existing_status=existing.status.value,
97
+ )
98
+ return existing, False
99
+
100
+ # Calculate deadline if specified
101
+ deadline_ts: datetime | None = None
102
+ if spec.deadline_seconds:
103
+ deadline_ts = datetime.now(timezone.utc) + timedelta(seconds=spec.deadline_seconds)
104
+
105
+ # Generate new intent ID
106
+ intent_id = uuid4()
107
+
108
+ # Merge trigger.reason into metadata (job metadata wins on key collision)
109
+ # This is immutable - don't mutate spec.metadata
110
+ base = spec.metadata or {}
111
+ if trigger:
112
+ metadata = {"reason": trigger.reason, **base}
113
+ else:
114
+ metadata = base if base else None
115
+
116
+ # Create intent in database
117
+ intent = db.create_intent(
118
+ intent_id=intent_id,
119
+ job_id=job_id,
120
+ chain_id=chain_id,
121
+ signer_address=spec.signer_address.lower(),
122
+ idempotency_key=idem_key,
123
+ to_address=spec.to_address.lower(),
124
+ data=spec.data,
125
+ value_wei=spec.value_wei,
126
+ gas_limit=spec.gas_limit,
127
+ max_fee_per_gas=str(spec.max_fee_per_gas) if spec.max_fee_per_gas else None,
128
+ max_priority_fee_per_gas=str(spec.max_priority_fee_per_gas) if spec.max_priority_fee_per_gas else None,
129
+ min_confirmations=spec.min_confirmations,
130
+ deadline_ts=deadline_ts,
131
+ broadcast_group=broadcast_group,
132
+ broadcast_endpoints=broadcast_endpoints,
133
+ metadata=metadata,
134
+ )
135
+
136
+ if intent is None:
137
+ # Race condition: another process created it between our check and insert
138
+ # This is expected with idempotency - just get the existing one
139
+ existing = db.get_intent_by_idempotency_key(
140
+ chain_id=chain_id,
141
+ signer_address=spec.signer_address.lower(),
142
+ idempotency_key=idem_key,
143
+ )
144
+ if existing:
145
+ logger.info(
146
+ LogEvents.INTENT_DEDUPE,
147
+ job_id=job_id,
148
+ idempotency_key=idem_key,
149
+ chain_id=chain_id,
150
+ signer=spec.signer_address.lower(),
151
+ existing_intent_id=str(existing.intent_id),
152
+ note="race_condition",
153
+ )
154
+ return existing, False
155
+ else:
156
+ raise RuntimeError(f"Failed to create or find intent with key {idem_key}")
157
+
158
+ logger.info(
159
+ LogEvents.INTENT_CREATE,
160
+ intent_id=str(intent.intent_id),
161
+ job_id=job_id,
162
+ idempotency_key=idem_key,
163
+ signer=spec.signer_address,
164
+ to=spec.to_address,
165
+ )
166
+
167
+ return intent, True
168
+
169
+
170
+ def get_or_create_intent(
171
+ db: Database,
172
+ job_id: str,
173
+ chain_id: int,
174
+ spec: TxIntentSpec,
175
+ idem_parts: list[str | int | bytes],
176
+ broadcast_group: str | None = None,
177
+ broadcast_endpoints: list[str] | None = None,
178
+ ) -> TxIntent:
179
+ """Get existing intent by idempotency key or create new one.
180
+
181
+ This is the primary API for jobs creating intents.
182
+ Ensures exactly-once semantics via idempotency.
183
+
184
+ Args:
185
+ db: Database connection
186
+ job_id: Job that triggered this intent
187
+ chain_id: Chain ID for the transaction
188
+ spec: Transaction specification
189
+ idem_parts: Parts to include in idempotency key
190
+
191
+ Returns:
192
+ The intent (existing or newly created)
193
+ """
194
+ intent, _ = create_intent(
195
+ db,
196
+ job_id,
197
+ chain_id,
198
+ spec,
199
+ idem_parts,
200
+ broadcast_group=broadcast_group,
201
+ broadcast_endpoints=broadcast_endpoints,
202
+ )
203
+ return intent
204
+
205
+
206
+ def claim_intent(
207
+ db: Database,
208
+ worker_id: str,
209
+ claimed_by: str | None = None,
210
+ ) -> TxIntent | None:
211
+ """Claim the next available intent for processing.
212
+
213
+ Uses FOR UPDATE SKIP LOCKED (PostgreSQL) or
214
+ IMMEDIATE transaction locking (SQLite) to prevent
215
+ multiple workers from claiming the same intent.
216
+
217
+ Args:
218
+ db: Database connection
219
+ worker_id: Unique identifier for this worker
220
+
221
+ Returns:
222
+ Claimed intent or None if no intents available
223
+ """
224
+ # Generate unique claim token
225
+ claim_token = f"{worker_id}_{uuid4().hex[:8]}"
226
+
227
+ intent = db.claim_next_intent(claim_token, claimed_by=claimed_by)
228
+
229
+ if intent:
230
+ logger.info(
231
+ LogEvents.INTENT_CLAIM,
232
+ intent_id=str(intent.intent_id),
233
+ job_id=intent.job_id,
234
+ worker_id=worker_id,
235
+ claim_token=claim_token,
236
+ )
237
+
238
+ return intent
239
+
240
+
241
+ def release_claim(db: Database, intent_id: UUID) -> bool:
242
+ """Release an intent claim without processing.
243
+
244
+ Use when a worker picks up an intent but cannot process it
245
+ (e.g., during graceful shutdown).
246
+
247
+ Args:
248
+ db: Database connection
249
+ intent_id: Intent to release
250
+
251
+ Returns:
252
+ True if released successfully
253
+ """
254
+ released = db.release_intent_claim(intent_id)
255
+
256
+ if released:
257
+ logger.info(
258
+ LogEvents.INTENT_STATUS,
259
+ intent_id=str(intent_id),
260
+ status="created",
261
+ action="claim_released",
262
+ )
263
+
264
+ return released
265
+
266
+
267
+ def update_status(
268
+ db: Database,
269
+ intent_id: UUID,
270
+ status: IntentStatus,
271
+ ) -> bool:
272
+ """Update intent status.
273
+
274
+ Args:
275
+ db: Database connection
276
+ intent_id: Intent to update
277
+ status: New status
278
+
279
+ Returns:
280
+ True if updated successfully
281
+ """
282
+ updated = db.update_intent_status(intent_id, status.value)
283
+
284
+ if updated:
285
+ logger.info(
286
+ LogEvents.INTENT_STATUS,
287
+ intent_id=str(intent_id),
288
+ status=status.value,
289
+ )
290
+
291
+ return updated
292
+
293
+
294
+ def transition_intent(
295
+ db: Database,
296
+ intent_id: UUID,
297
+ to_status: IntentStatus,
298
+ reason: str,
299
+ chain_id: int | None = None,
300
+ ) -> bool:
301
+ """Transition an intent using the centralized transition map.
302
+
303
+ Uses atomic transition that clears claim fields when leaving CLAIMED status.
304
+ """
305
+ allowed_from = [
306
+ from_status
307
+ for from_status, allowed in ALLOWED_TRANSITIONS.items()
308
+ if to_status.value in allowed
309
+ ]
310
+
311
+ if not allowed_from:
312
+ logger.error(
313
+ "intent.transition.forbidden",
314
+ intent_id=str(intent_id),
315
+ to_status=to_status.value,
316
+ reason=reason,
317
+ )
318
+ return False
319
+
320
+ # Single atomic operation - DB handles claim clearing internally
321
+ success, old_status = db.transition_intent_status(
322
+ intent_id=intent_id,
323
+ from_statuses=allowed_from,
324
+ to_status=to_status.value,
325
+ )
326
+
327
+ if success:
328
+ # Emit metrics with ACTUAL previous status
329
+ metrics = get_metrics()
330
+ metrics.counter(INTENT_TRANSITIONS).inc(
331
+ chain_id=chain_id if chain_id is not None else "unknown",
332
+ from_status=old_status if old_status else "unknown",
333
+ to_status=to_status.value,
334
+ reason=reason,
335
+ )
336
+ logger.info(
337
+ "intent.transition",
338
+ intent_id=str(intent_id),
339
+ from_status=old_status,
340
+ to_status=to_status.value,
341
+ reason=reason,
342
+ )
343
+ else:
344
+ logger.debug(
345
+ "intent.transition.skipped",
346
+ intent_id=str(intent_id),
347
+ to_status=to_status.value,
348
+ reason="status_mismatch",
349
+ )
350
+
351
+ return success
352
+
353
+
354
+ def abandon_intent(
355
+ db: Database,
356
+ intent_id: UUID,
357
+ reason: str = "abandoned",
358
+ chain_id: int | None = None,
359
+ ) -> bool:
360
+ """Mark an intent as abandoned.
361
+
362
+ Delegates to transition_intent() for validated state transitions.
363
+
364
+ Use when:
365
+ - Deadline expired
366
+ - Max replacement attempts exceeded
367
+ - Manual intervention required
368
+
369
+ Args:
370
+ db: Database connection
371
+ intent_id: Intent to abandon
372
+ reason: Reason for abandonment
373
+ chain_id: Chain ID for metrics
374
+
375
+ Returns:
376
+ True if abandoned successfully
377
+ """
378
+ return transition_intent(
379
+ db, intent_id, IntentStatus.ABANDONED, reason, chain_id=chain_id
380
+ )
381
+
382
+
383
+ def get_pending_for_signer(
384
+ db: Database,
385
+ chain_id: int,
386
+ signer_address: str,
387
+ ) -> list[TxIntent]:
388
+ """Get all pending intents for a signer.
389
+
390
+ Use for startup reconciliation to find in-flight transactions.
391
+
392
+ Args:
393
+ db: Database connection
394
+ chain_id: Chain ID
395
+ signer_address: Signer address
396
+
397
+ Returns:
398
+ List of pending intents
399
+ """
400
+ return db.get_pending_intents_for_signer(chain_id, signer_address.lower())
401
+
402
+
403
+ def revert_to_pending(
404
+ db: Database,
405
+ intent_id: UUID,
406
+ chain_id: int | None = None,
407
+ ) -> bool:
408
+ """Revert a confirmed intent to pending status (for reorg handling).
409
+
410
+ Delegates to transition_intent() for validated state transitions.
411
+ Called when a confirmed intent's block is invalidated by a reorg.
412
+
413
+ Args:
414
+ db: Database connection
415
+ intent_id: Intent to revert
416
+ chain_id: Chain ID for metrics
417
+
418
+ Returns:
419
+ True if reverted successfully
420
+ """
421
+ return transition_intent(
422
+ db, intent_id, IntentStatus.PENDING, "reorg_reverted", chain_id=chain_id
423
+ )