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
brawny/tx/intent.py CHANGED
@@ -17,10 +17,14 @@ from uuid import UUID, uuid4
17
17
  from brawny.logging import LogEvents, get_logger
18
18
  from brawny.metrics import INTENT_TRANSITIONS, get_metrics
19
19
  from brawny.model.enums import IntentStatus
20
+ from brawny.model.errors import CancelledCheckError
20
21
  from brawny.model.types import TxIntent, TxIntentSpec, Trigger, idempotency_key
22
+ from brawny.types import ClaimedIntent
23
+ from brawny.utils import db_address, is_valid_address
21
24
 
22
25
  if TYPE_CHECKING:
23
26
  from brawny.db.base import Database
27
+ from brawny.model.contexts import CancellationToken
24
28
 
25
29
  logger = get_logger(__name__)
26
30
 
@@ -58,6 +62,7 @@ def create_intent(
58
62
  broadcast_group: str | None = None,
59
63
  broadcast_endpoints: list[str] | None = None,
60
64
  trigger: Trigger | None = None,
65
+ cancellation_token: "CancellationToken | None" = None,
61
66
  ) -> tuple[TxIntent, bool]:
62
67
  """Create a new transaction intent with idempotency.
63
68
 
@@ -76,13 +81,28 @@ def create_intent(
76
81
  Returns:
77
82
  Tuple of (intent, is_new) where is_new is True if newly created
78
83
  """
84
+ # Resolve signer alias early; idempotency is scoped to canonical address.
85
+ signer_alias: str | None = None
86
+ if is_valid_address(spec.signer_address):
87
+ resolved_signer = spec.signer_address
88
+ else:
89
+ if isinstance(spec.signer_address, str) and spec.signer_address.startswith("0x"):
90
+ raise ValueError(f"Invalid signer address: {spec.signer_address}")
91
+ from brawny.api import get_address_from_alias
92
+
93
+ signer_alias = spec.signer_address
94
+ resolved_signer = get_address_from_alias(spec.signer_address)
95
+
96
+ canonical_signer = db_address(resolved_signer)
97
+ canonical_to = db_address(spec.to_address)
98
+
79
99
  # Generate idempotency key from job_id and parts
80
100
  idem_key = idempotency_key(job_id, *idem_parts)
81
101
 
82
102
  # Check for existing intent (scoped to chain + signer)
83
103
  existing = db.get_intent_by_idempotency_key(
84
104
  chain_id=chain_id,
85
- signer_address=spec.signer_address.lower(),
105
+ signer_address=canonical_signer,
86
106
  idempotency_key=idem_key,
87
107
  )
88
108
  if existing:
@@ -91,12 +111,15 @@ def create_intent(
91
111
  job_id=job_id,
92
112
  idempotency_key=idem_key,
93
113
  chain_id=chain_id,
94
- signer=spec.signer_address.lower(),
114
+ signer=canonical_signer,
95
115
  existing_intent_id=str(existing.intent_id),
96
116
  existing_status=existing.status.value,
97
117
  )
98
118
  return existing, False
99
119
 
120
+ if cancellation_token is not None and cancellation_token.cancelled:
121
+ raise CancelledCheckError("Check cancelled before intent creation")
122
+
100
123
  # Calculate deadline if specified
101
124
  deadline_ts: datetime | None = None
102
125
  if spec.deadline_seconds:
@@ -118,9 +141,10 @@ def create_intent(
118
141
  intent_id=intent_id,
119
142
  job_id=job_id,
120
143
  chain_id=chain_id,
121
- signer_address=spec.signer_address.lower(),
144
+ signer_address=canonical_signer,
145
+ signer_alias=signer_alias,
122
146
  idempotency_key=idem_key,
123
- to_address=spec.to_address.lower(),
147
+ to_address=canonical_to,
124
148
  data=spec.data,
125
149
  value_wei=spec.value_wei,
126
150
  gas_limit=spec.gas_limit,
@@ -138,7 +162,7 @@ def create_intent(
138
162
  # This is expected with idempotency - just get the existing one
139
163
  existing = db.get_intent_by_idempotency_key(
140
164
  chain_id=chain_id,
141
- signer_address=spec.signer_address.lower(),
165
+ signer_address=canonical_signer,
142
166
  idempotency_key=idem_key,
143
167
  )
144
168
  if existing:
@@ -147,7 +171,7 @@ def create_intent(
147
171
  job_id=job_id,
148
172
  idempotency_key=idem_key,
149
173
  chain_id=chain_id,
150
- signer=spec.signer_address.lower(),
174
+ signer=canonical_signer,
151
175
  existing_intent_id=str(existing.intent_id),
152
176
  note="race_condition",
153
177
  )
@@ -160,8 +184,8 @@ def create_intent(
160
184
  intent_id=str(intent.intent_id),
161
185
  job_id=job_id,
162
186
  idempotency_key=idem_key,
163
- signer=spec.signer_address,
164
- to=spec.to_address,
187
+ signer=canonical_signer,
188
+ to=canonical_to,
165
189
  )
166
190
 
167
191
  return intent, True
@@ -207,11 +231,11 @@ def claim_intent(
207
231
  db: Database,
208
232
  worker_id: str,
209
233
  claimed_by: str | None = None,
210
- ) -> TxIntent | None:
234
+ lease_seconds: int = 300,
235
+ ) -> ClaimedIntent | None:
211
236
  """Claim the next available intent for processing.
212
237
 
213
- Uses FOR UPDATE SKIP LOCKED (PostgreSQL) or
214
- IMMEDIATE transaction locking (SQLite) to prevent
238
+ Uses IMMEDIATE transaction locking (SQLite) to prevent
215
239
  multiple workers from claiming the same intent.
216
240
 
217
241
  Args:
@@ -224,18 +248,21 @@ def claim_intent(
224
248
  # Generate unique claim token
225
249
  claim_token = f"{worker_id}_{uuid4().hex[:8]}"
226
250
 
227
- intent = db.claim_next_intent(claim_token, claimed_by=claimed_by)
251
+ claimed = db.claim_next_intent(
252
+ claim_token,
253
+ claimed_by=claimed_by,
254
+ lease_seconds=lease_seconds,
255
+ )
228
256
 
229
- if intent:
257
+ if claimed:
230
258
  logger.info(
231
259
  LogEvents.INTENT_CLAIM,
232
- intent_id=str(intent.intent_id),
233
- job_id=intent.job_id,
260
+ intent_id=str(claimed.intent_id),
234
261
  worker_id=worker_id,
235
262
  claim_token=claim_token,
236
263
  )
237
264
 
238
- return intent
265
+ return claimed
239
266
 
240
267
 
241
268
  def release_claim(db: Database, intent_id: UUID) -> bool:
@@ -297,6 +324,8 @@ def transition_intent(
297
324
  to_status: IntentStatus,
298
325
  reason: str,
299
326
  chain_id: int | None = None,
327
+ actor: str | None = None,
328
+ source: str | None = None,
300
329
  ) -> bool:
301
330
  """Transition an intent using the centralized transition map.
302
331
 
@@ -333,6 +362,15 @@ def transition_intent(
333
362
  to_status=to_status.value,
334
363
  reason=reason,
335
364
  )
365
+ db.record_mutation_audit(
366
+ entity_type="intent",
367
+ entity_id=str(intent_id),
368
+ action=f"transition:{to_status.value}",
369
+ actor=actor,
370
+ reason=reason,
371
+ source=source,
372
+ metadata={"from_status": old_status, "to_status": to_status.value},
373
+ )
336
374
  logger.info(
337
375
  "intent.transition",
338
376
  intent_id=str(intent_id),
brawny/tx/monitor.py CHANGED
@@ -30,6 +30,7 @@ from brawny.metrics import (
30
30
  from brawny.model.enums import AttemptStatus, IntentStatus, NonceStatus
31
31
  from brawny.model.errors import DatabaseError, FailureType, FailureStage
32
32
  from brawny._rpc.errors import RPCError
33
+ from brawny.timeout import Deadline
33
34
  from brawny.tx.intent import transition_intent
34
35
 
35
36
  if TYPE_CHECKING:
@@ -37,11 +38,13 @@ if TYPE_CHECKING:
37
38
  from brawny.db.base import Database
38
39
  from brawny.lifecycle import LifecycleDispatcher
39
40
  from brawny.model.types import TxAttempt, TxIntent
40
- from brawny._rpc.manager import RPCManager
41
+ from brawny._rpc.clients import ReadClient
41
42
  from brawny.tx.nonce import NonceManager
42
43
 
43
44
  logger = get_logger(__name__)
44
45
 
46
+ MONITOR_TICK_TIMEOUT_SECONDS = 10.0
47
+
45
48
 
46
49
  class ConfirmationResult(str, Enum):
47
50
  """Result of confirmation monitoring."""
@@ -78,7 +81,7 @@ class TxMonitor:
78
81
  def __init__(
79
82
  self,
80
83
  db: Database,
81
- rpc: RPCManager,
84
+ rpc: ReadClient,
82
85
  nonce_manager: NonceManager,
83
86
  config: Config,
84
87
  lifecycle: "LifecycleDispatcher | None" = None,
@@ -101,6 +104,7 @@ class TxMonitor:
101
104
  self,
102
105
  intent: TxIntent,
103
106
  attempt: TxAttempt,
107
+ deadline: Deadline | None = None,
104
108
  ) -> ConfirmationStatus:
105
109
  """Check confirmation status for a transaction attempt.
106
110
 
@@ -114,6 +118,8 @@ class TxMonitor:
114
118
  Returns:
115
119
  Current confirmation status
116
120
  """
121
+ if deadline is not None and deadline.expired():
122
+ return ConfirmationStatus(result=ConfirmationResult.PENDING)
117
123
  if not attempt.tx_hash:
118
124
  logger.warning(
119
125
  "monitor.no_tx_hash",
@@ -123,11 +129,11 @@ class TxMonitor:
123
129
  return ConfirmationStatus(result=ConfirmationResult.PENDING)
124
130
 
125
131
  # Get receipt
126
- receipt = self._rpc.get_transaction_receipt(attempt.tx_hash)
132
+ receipt = self._rpc.get_transaction_receipt(attempt.tx_hash, deadline=deadline)
127
133
 
128
134
  if receipt is None:
129
135
  # No receipt yet - check if nonce has been consumed by another tx
130
- if self._is_nonce_consumed(intent, attempt):
136
+ if self._is_nonce_consumed(intent, attempt, deadline):
131
137
  metrics = get_metrics()
132
138
  metrics.counter(TX_FAILED).inc(
133
139
  chain_id=intent.chain_id,
@@ -137,7 +143,7 @@ class TxMonitor:
137
143
  return ConfirmationStatus(result=ConfirmationResult.DROPPED)
138
144
 
139
145
  # Check if stuck
140
- if self._is_stuck(attempt):
146
+ if self._is_stuck(attempt, deadline):
141
147
  metrics = get_metrics()
142
148
  metrics.counter(TX_FAILED).inc(
143
149
  chain_id=intent.chain_id,
@@ -161,7 +167,7 @@ class TxMonitor:
161
167
 
162
168
  # Verify block hash matches current chain
163
169
  try:
164
- current_block = self._rpc.get_block(receipt_block_number)
170
+ current_block = self._rpc.get_block(receipt_block_number, deadline=deadline)
165
171
  current_hash = current_block.get("hash")
166
172
  if hasattr(current_hash, "hex"):
167
173
  current_hash = current_hash.hex()
@@ -188,7 +194,7 @@ class TxMonitor:
188
194
  return ConfirmationStatus(result=ConfirmationResult.PENDING)
189
195
 
190
196
  # Count confirmations
191
- current_block_number = self._rpc.get_block_number()
197
+ current_block_number = self._rpc.get_block_number(deadline=deadline)
192
198
  confirmations = current_block_number - receipt_block_number + 1
193
199
 
194
200
  # Check if confirmed with enough confirmations
@@ -241,7 +247,12 @@ class TxMonitor:
241
247
  block_hash=receipt_block_hash,
242
248
  )
243
249
 
244
- def _is_nonce_consumed(self, intent: TxIntent, attempt: TxAttempt) -> bool:
250
+ def _is_nonce_consumed(
251
+ self,
252
+ intent: TxIntent,
253
+ attempt: TxAttempt,
254
+ deadline: Deadline | None,
255
+ ) -> bool:
245
256
  """Check if the nonce has been consumed by another transaction.
246
257
 
247
258
  Args:
@@ -257,12 +268,13 @@ class TxMonitor:
257
268
  chain_nonce = self._rpc.get_transaction_count(
258
269
  signer_address,
259
270
  "latest", # Use "latest" not "pending" to check confirmed
271
+ deadline=deadline,
260
272
  )
261
273
 
262
274
  # If chain nonce is greater than our nonce, it was consumed
263
275
  if chain_nonce > attempt.nonce:
264
276
  # Verify our tx isn't the one that consumed it
265
- receipt = self._rpc.get_transaction_receipt(attempt.tx_hash)
277
+ receipt = self._rpc.get_transaction_receipt(attempt.tx_hash, deadline=deadline)
266
278
  if receipt is None:
267
279
  # Nonce consumed but not by our tx
268
280
  logger.warning(
@@ -281,7 +293,7 @@ class TxMonitor:
281
293
  )
282
294
  return False
283
295
 
284
- def _is_stuck(self, attempt: TxAttempt) -> bool:
296
+ def _is_stuck(self, attempt: TxAttempt, deadline: Deadline | None) -> bool:
285
297
  """Check if transaction is stuck.
286
298
 
287
299
  Stuck is defined as:
@@ -304,7 +316,7 @@ class TxMonitor:
304
316
 
305
317
  # Check blocks elapsed
306
318
  try:
307
- current_block = self._rpc.get_block_number()
319
+ current_block = self._rpc.get_block_number(deadline=deadline)
308
320
  blocks_since = current_block - attempt.broadcast_block
309
321
  if blocks_since > self._config.stuck_tx_blocks:
310
322
  return True
@@ -584,10 +596,17 @@ class TxMonitor:
584
596
  }
585
597
 
586
598
  pending = self.get_pending_attempts()
599
+ deadline = Deadline.from_seconds(MONITOR_TICK_TIMEOUT_SECONDS)
587
600
 
588
601
  for intent, attempt in pending:
602
+ if deadline.expired():
603
+ logger.warning(
604
+ "monitor.tick_timeout",
605
+ pending_remaining=len(pending),
606
+ )
607
+ break
589
608
  try:
590
- status = self.check_confirmation(intent, attempt)
609
+ status = self.check_confirmation(intent, attempt, deadline=deadline)
591
610
 
592
611
  if status.result == ConfirmationResult.CONFIRMED:
593
612
  self.handle_confirmed(intent, attempt, status)