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
@@ -0,0 +1,319 @@
1
+ """Phase-specific contexts for the job lifecycle.
2
+
3
+ Each phase gets only what it needs:
4
+ - CheckContext: Read chain state, return Trigger. KV is read+write.
5
+ - BuildContext: Produces TxSpec. Has trigger + signer. KV is read-only.
6
+ - AlertContext: Receives immutable snapshots. KV is read-only.
7
+
8
+ Contract access is explicit and block-aware:
9
+ - at_block(name, addr, block): Pinned reads for check()
10
+ - at(name, addr): Latest reads for build/alerts
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any, Protocol
17
+
18
+ from brawny.model.events import DecodedEvent
19
+ from brawny.model.errors import FailureType
20
+
21
+ if TYPE_CHECKING:
22
+ from brawny.jobs.kv import KVReader, KVStore
23
+ from brawny.model.types import Trigger, TxIntent, TxAttempt
24
+ from brawny.jobs.base import TxInfo, TxReceipt
25
+ from brawny.alerts.send import AlertConfig
26
+ from brawny.config.models import TelegramConfig
27
+ from brawny.telegram import TelegramBot
28
+ import structlog
29
+
30
+
31
+ # =============================================================================
32
+ # Contract Factory Protocol
33
+ # =============================================================================
34
+
35
+
36
+ class ContractHandle(Protocol):
37
+ """Protocol for contract handles. Actual implementation in alerts/contracts.py."""
38
+
39
+ @property
40
+ def address(self) -> str:
41
+ """Contract address (checksummed)."""
42
+ ...
43
+
44
+ def _call_with_calldata(self, calldata: str, abi: Any) -> Any:
45
+ """Execute eth_call with pre-encoded calldata."""
46
+ ...
47
+
48
+
49
+ class ContractFactory(Protocol):
50
+ """Factory for creating contract handles.
51
+
52
+ Block-aware contract access:
53
+ - at(): Get handle reading at 'latest'. Use in build/alerts.
54
+ - at_block(): Get handle pinned to specific block. Use in check().
55
+ - with_abi(): Get handle with explicit ABI.
56
+
57
+ Factory stays dumb:
58
+ - Does not silently switch endpoints/groups
59
+ - Does not mutate global caches
60
+ - Is deterministic under a given rpc + abi_resolver
61
+ - Factory binds handles; resolver owns policy
62
+ """
63
+
64
+ def at(self, name: str, address: str) -> ContractHandle:
65
+ """Get contract handle, reads at 'latest'. Use in build/alerts."""
66
+ ...
67
+
68
+ def at_block(self, name: str, address: str, block: int) -> ContractHandle:
69
+ """Get contract handle pinned to specific block. Use in check()."""
70
+ ...
71
+
72
+ def with_abi(self, address: str, abi: list[Any]) -> ContractHandle:
73
+ """Get contract handle with explicit ABI."""
74
+ ...
75
+
76
+
77
+ # =============================================================================
78
+ # Block Context (Immutable Snapshot)
79
+ # =============================================================================
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class BlockContext:
84
+ """Immutable chain state snapshot at check() time.
85
+
86
+ Contains only chain state metadata. Jobs can still make RPC calls,
87
+ but check() reads should be block-pinned using ctx.block.number.
88
+ """
89
+
90
+ number: int
91
+ timestamp: int
92
+ hash: str
93
+ base_fee: int
94
+ chain_id: int
95
+
96
+
97
+ # =============================================================================
98
+ # Check Context
99
+ # =============================================================================
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class CheckContext:
104
+ """Context available during check(). Read chain state, return Trigger.
105
+
106
+ Semantic rules:
107
+ - check() may read chain state + mutate kv
108
+ - Returns Trigger | None
109
+ - Use ctx.contracts.at_block(name, addr, ctx.block.number) for block-pinned reads
110
+ """
111
+
112
+ block: BlockContext
113
+ kv: KVStore # Read+write allowed
114
+ job_id: str
115
+ rpc: Any # RPCManager or similar
116
+ logger: "structlog.stdlib.BoundLogger"
117
+ contracts: ContractFactory
118
+ _db: Any = None # Internal: for log()
119
+
120
+ def log(self, level: str = "info", **fields: Any) -> None:
121
+ """Record structured snapshot. Best-effort, never interrupts."""
122
+ if self._db is None:
123
+ return
124
+ try:
125
+ from brawny.db.ops import logs as log_ops
126
+ log_ops.insert_log(
127
+ self._db, self.block.chain_id, self.job_id,
128
+ self.block.number, level, fields
129
+ )
130
+ except Exception as e:
131
+ self.logger.warning("log_failed", error=str(e))
132
+
133
+
134
+ # =============================================================================
135
+ # Build Context
136
+ # =============================================================================
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class BuildContext:
141
+ """Context available during build_tx(). Produces transaction spec.
142
+
143
+ Semantic rules:
144
+ - build_tx() produces a TxSpec
145
+ - Has trigger + signer_address
146
+ - KV is read-only
147
+ - ctx.contracts.at(name, addr) defaults to 'latest', fine for build
148
+ - Safety-critical predicates should be computed in check() and encoded in Trigger.data
149
+ """
150
+
151
+ block: BlockContext
152
+ trigger: "Trigger"
153
+ job_id: str
154
+ signer_address: str # Signer belongs here, not on RPC
155
+ rpc: Any # RPCManager or similar
156
+ logger: "structlog.stdlib.BoundLogger"
157
+ contracts: ContractFactory
158
+ kv: KVReader # Read-only
159
+
160
+
161
+ # =============================================================================
162
+ # Alert Context
163
+ # =============================================================================
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class AlertContext:
168
+ """Context available during alert hooks. Immutable snapshots only.
169
+
170
+ Semantic rules:
171
+ - Alert hooks receive immutable snapshots
172
+ - KV is read-only
173
+ - events is raw list[DecodedEvent], not an accessor framework
174
+ - chain_id comes from block.chain_id (no duplication)
175
+ """
176
+
177
+ block: BlockContext
178
+ trigger: "Trigger"
179
+ tx: "TxInfo | None"
180
+ receipt: "TxReceipt | None"
181
+ events: list[DecodedEvent] | None # Raw list, not a framework
182
+ failure_type: FailureType | None
183
+ error_info: Any | None # ErrorInfo for failure context
184
+ logger: "structlog.stdlib.BoundLogger"
185
+ contracts: ContractFactory
186
+ kv: KVReader # Read-only
187
+
188
+ @property
189
+ def has_receipt(self) -> bool:
190
+ """Check if receipt is available."""
191
+ return self.receipt is not None
192
+
193
+ @property
194
+ def has_error(self) -> bool:
195
+ """Check if this is a failure context."""
196
+ return self.failure_type is not None
197
+
198
+ @property
199
+ def error_message(self) -> str:
200
+ """Convenience: error message or 'unknown'."""
201
+ if self.error_info is not None and hasattr(self.error_info, "message"):
202
+ return self.error_info.message
203
+ return "unknown"
204
+
205
+ @property
206
+ def chain_id(self) -> int:
207
+ """Chain ID from block context."""
208
+ return self.block.chain_id
209
+
210
+
211
+ # =============================================================================
212
+ # Hook Contexts (New Simplified API)
213
+ # =============================================================================
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class TriggerContext:
218
+ """Passed to on_trigger. Trigger still exists here.
219
+
220
+ Use for:
221
+ - Monitor-only jobs (tx_required=False) - your only hook
222
+ - Pre-transaction alerts/logging
223
+ - KV updates before intent creation
224
+
225
+ Note: No intent exists yet. After this hook, trigger is gone -
226
+ only intent.metadata persists.
227
+ """
228
+
229
+ trigger: "Trigger"
230
+ block: BlockContext
231
+ kv: "KVStore" # Read+write (pre-intent)
232
+ logger: "structlog.stdlib.BoundLogger"
233
+ # Alert routing
234
+ job_id: str
235
+ job_name: str
236
+ chain_id: int
237
+ alert_config: "AlertConfig"
238
+ telegram_config: "TelegramConfig" # Always exists (Config.telegram has default factory)
239
+ # Optional telegram fields
240
+ telegram_bot: "TelegramBot | None" = None
241
+ job_alert_to: list[str] | None = None
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class SuccessContext:
246
+ """Passed to on_success. No trigger - use intent.metadata.
247
+
248
+ ctx.intent.metadata["reason"] = original trigger.reason
249
+ ctx.intent.metadata[...] = your custom data from build_tx()
250
+ """
251
+
252
+ intent: "TxIntent"
253
+ receipt: "TxReceipt"
254
+ events: list[DecodedEvent] | None
255
+ block: BlockContext
256
+ kv: "KVReader" # Read-only
257
+ logger: "structlog.stdlib.BoundLogger"
258
+ # Alert routing
259
+ job_id: str
260
+ job_name: str
261
+ chain_id: int
262
+ alert_config: "AlertConfig"
263
+ telegram_config: "TelegramConfig" # Always exists (Config.telegram has default factory)
264
+ # Optional telegram fields
265
+ telegram_bot: "TelegramBot | None" = None
266
+ job_alert_to: list[str] | None = None
267
+
268
+
269
+ @dataclass(frozen=True)
270
+ class FailureContext:
271
+ """Passed to on_failure. Intent may be None for pre-intent failures.
272
+
273
+ Pre-intent failures include:
274
+ - check() exception
275
+ - build_tx() exception
276
+ - intent creation failure
277
+ """
278
+
279
+ intent: "TxIntent | None" # None for pre-intent failures
280
+ attempt: "TxAttempt | None" # intent None for pre-intent; attempt may be None even post-intent
281
+ error: Exception
282
+ failure_type: FailureType
283
+ failure_stage: FailureStage | None
284
+ block: BlockContext
285
+ kv: "KVReader" # Read-only (no side effects during failure handling)
286
+ logger: "structlog.stdlib.BoundLogger"
287
+ # Alert routing
288
+ job_id: str
289
+ job_name: str
290
+ chain_id: int
291
+ alert_config: "AlertConfig"
292
+ telegram_config: "TelegramConfig" # Always exists (Config.telegram has default factory)
293
+ # Optional telegram fields
294
+ telegram_bot: "TelegramBot | None" = None
295
+ job_alert_to: list[str] | None = None
296
+
297
+
298
+ # =============================================================================
299
+ # Context Size Caps (Prevent Re-Growth)
300
+ # =============================================================================
301
+
302
+ # These are validated in tests to prevent gradual re-growth into god object:
303
+ # - BlockContext: <= 5 fields
304
+ # - CheckContext: <= 7 fields
305
+ # - BuildContext: <= 9 fields
306
+ # - AlertContext: <= 10 fields
307
+
308
+
309
+ __all__ = [
310
+ "BlockContext",
311
+ "CheckContext",
312
+ "BuildContext",
313
+ "AlertContext",
314
+ "TriggerContext",
315
+ "SuccessContext",
316
+ "FailureContext",
317
+ "ContractFactory",
318
+ "ContractHandle",
319
+ ]
brawny/model/enums.py ADDED
@@ -0,0 +1,70 @@
1
+ """Enumerations for brawny statuses and states."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class IntentStatus(str, Enum):
7
+ """Transaction intent status."""
8
+
9
+ CREATED = "created"
10
+ CLAIMED = "claimed"
11
+ SENDING = "sending"
12
+ PENDING = "pending"
13
+ CONFIRMED = "confirmed"
14
+ FAILED = "failed"
15
+ ABANDONED = "abandoned"
16
+
17
+
18
+ class AttemptStatus(str, Enum):
19
+ """Transaction attempt status."""
20
+
21
+ SIGNED = "signed"
22
+ BROADCAST = "broadcast"
23
+ PENDING = "pending"
24
+ CONFIRMED = "confirmed"
25
+ FAILED = "failed"
26
+ REPLACED = "replaced"
27
+
28
+
29
+ class NonceStatus(str, Enum):
30
+ """Nonce reservation status."""
31
+
32
+ RESERVED = "reserved"
33
+ IN_FLIGHT = "in_flight"
34
+ RELEASED = "released"
35
+ ORPHANED = "orphaned"
36
+
37
+
38
+ class TxStatus(str, Enum):
39
+ """Transaction lifecycle status. 4 states only.
40
+
41
+ IMPORTANT: Do not add new statuses. This enum is intentionally minimal.
42
+ """
43
+
44
+ CREATED = "created" # Exists, not yet broadcast
45
+ BROADCAST = "broadcast" # Has current_tx_hash, awaiting confirmation
46
+ CONFIRMED = "confirmed" # Terminal: receipt status=1
47
+ FAILED = "failed" # Terminal: permanent failure
48
+
49
+
50
+ class ABISource(str, Enum):
51
+ """Source of ABI data."""
52
+
53
+ ETHERSCAN = "etherscan"
54
+ SOURCIFY = "sourcify"
55
+ MANUAL = "manual"
56
+ PROXY_IMPLEMENTATION = "proxy_implementation"
57
+
58
+
59
+ class KeystoreType(str, Enum):
60
+ """Keystore type for private key management."""
61
+
62
+ ENV = "env"
63
+ FILE = "file"
64
+
65
+
66
+ class LogFormat(str, Enum):
67
+ """Log output format."""
68
+
69
+ JSON = "json"
70
+ TEXT = "text"
brawny/model/errors.py ADDED
@@ -0,0 +1,194 @@
1
+ """Error types for brawny."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class HookType(Enum):
8
+ """Alert hook types."""
9
+
10
+ TRIGGERED = "triggered"
11
+ CONFIRMED = "confirmed"
12
+ FAILED = "failed"
13
+
14
+
15
+ class TriggerReason:
16
+ """Constants for synthetic trigger reasons (not from job.check())."""
17
+
18
+ MISSING = "missing_trigger"
19
+ UNKNOWN_VERSION = "unknown_version"
20
+ INVALID_FORMAT = "invalid_format"
21
+ # CHECK_EXCEPTION and BUILD_TX_EXCEPTION use FailureType.*.value
22
+
23
+
24
+ @dataclass
25
+ class ErrorInfo:
26
+ """Structured error for alert context. JSON-safe."""
27
+
28
+ error_type: str # e.g., "SimulationReverted", "BroadcastFailed"
29
+ message: str # Truncated, sanitized
30
+ code: str | None = None # RPC error code if applicable
31
+
32
+ @classmethod
33
+ def from_exception(cls, e: Exception, max_len: int = 200) -> "ErrorInfo":
34
+ """Create ErrorInfo from an exception."""
35
+ code = getattr(e, "code", None)
36
+ return cls(
37
+ error_type=type(e).__name__,
38
+ message=str(e)[:max_len],
39
+ code=str(code) if code is not None else None,
40
+ )
41
+
42
+
43
+ class FailureStage(Enum):
44
+ """When in the lifecycle did the failure occur."""
45
+
46
+ PRE_BROADCAST = "pre_broadcast" # Never made it to chain
47
+ BROADCAST = "broadcast" # Failed during broadcast
48
+ POST_BROADCAST = "post_broadcast" # Failed after broadcast (on-chain)
49
+
50
+
51
+ class FailureType(Enum):
52
+ """What specifically failed."""
53
+
54
+ # Permanent (no retry will help)
55
+ SIMULATION_REVERTED = "simulation_reverted" # TX would revert
56
+ TX_REVERTED = "tx_reverted" # On-chain revert
57
+ DEADLINE_EXPIRED = "deadline_expired" # Intent too old
58
+
59
+ # Transient (might resolve on retry)
60
+ SIMULATION_NETWORK_ERROR = "simulation_network_error" # RPC issue during sim
61
+ BROADCAST_FAILED = "broadcast_failed" # RPC rejected tx
62
+
63
+ # Pre-broadcast failures (kept for backward compat)
64
+ SIGNER_FAILED = "signer_failed" # Keystore/signer issue
65
+ NONCE_FAILED = "nonce_failed" # Couldn't reserve nonce
66
+ SIGN_FAILED = "sign_failed" # Signing error
67
+ NONCE_CONSUMED = "nonce_consumed" # Nonce used elsewhere
68
+
69
+ # Superseded (replaced by new attempt)
70
+ SUPERSEDED = "superseded"
71
+
72
+ # Reorg (terminal - we don't retry reorged intents)
73
+ REORGED = "reorged"
74
+
75
+ # Pre-intent exceptions (no intent created yet)
76
+ CHECK_EXCEPTION = "check_exception" # job.check() crashed
77
+ BUILD_TX_EXCEPTION = "build_tx_exception" # job.build_tx() crashed
78
+
79
+ # Fallback
80
+ UNKNOWN = "unknown"
81
+
82
+
83
+ class BrawnyError(Exception):
84
+ """Base exception for all brawny errors."""
85
+
86
+ pass
87
+
88
+
89
+ class ConfigError(BrawnyError):
90
+ """Configuration error."""
91
+
92
+ pass
93
+
94
+
95
+ class DatabaseError(BrawnyError):
96
+ """Database operation error."""
97
+
98
+ pass
99
+
100
+
101
+ class NonceError(BrawnyError):
102
+ """Nonce management error."""
103
+
104
+ pass
105
+
106
+
107
+ class NonceReservationError(NonceError):
108
+ """Failed to reserve a nonce."""
109
+
110
+ pass
111
+
112
+
113
+ class IntentError(BrawnyError):
114
+ """Transaction intent error."""
115
+
116
+ pass
117
+
118
+
119
+ class RetriableExecutionError(BrawnyError):
120
+ """Temporary execution error that should be retried with backoff.
121
+
122
+ Used when execution cannot proceed temporarily (e.g., no gas quote available)
123
+ but may succeed after a backoff period.
124
+ """
125
+
126
+ pass
127
+
128
+
129
+ class JobError(BrawnyError):
130
+ """Job execution error."""
131
+
132
+ pass
133
+
134
+
135
+ class ReorgError(BrawnyError):
136
+ """Blockchain reorganization error."""
137
+
138
+ pass
139
+
140
+
141
+ class KeystoreError(BrawnyError):
142
+ """Keystore or signing error."""
143
+
144
+ pass
145
+
146
+
147
+ class ABIResolutionError(BrawnyError):
148
+ """ABI resolution failed."""
149
+
150
+ pass
151
+
152
+
153
+ class ReceiptRequiredError(BrawnyError):
154
+ """Attempted to access receipt-only features without a receipt."""
155
+
156
+ pass
157
+
158
+
159
+ class EventNotFoundError(BrawnyError):
160
+ """Event not found in receipt logs."""
161
+
162
+ pass
163
+
164
+
165
+ class StateChangingCallError(BrawnyError):
166
+ """Attempted to call a state-changing function in a read-only context."""
167
+
168
+ pass
169
+
170
+
171
+ class CircuitBreakerOpenError(BrawnyError):
172
+ """All RPC endpoints are unhealthy."""
173
+
174
+ pass
175
+
176
+
177
+ class DatabaseCircuitBreakerOpenError(BrawnyError):
178
+ """Database circuit breaker is open."""
179
+
180
+ pass
181
+
182
+
183
+ class SimulationReverted(BrawnyError):
184
+ """Transaction would revert on-chain. Permanent failure - do not retry or broadcast."""
185
+
186
+ def __init__(self, reason: str) -> None:
187
+ super().__init__(reason)
188
+ self.reason = reason
189
+
190
+
191
+ class SimulationNetworkError(BrawnyError):
192
+ """Network/RPC error during simulation. Transient - may retry."""
193
+
194
+ pass
brawny/model/events.py ADDED
@@ -0,0 +1,93 @@
1
+ """Event decoding types and helpers.
2
+
3
+ DecodedEvent is a frozen dataclass representing a single decoded log event.
4
+ Helper functions are pure functions, not methods.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from types import MappingProxyType
11
+ from typing import Any, Mapping
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DecodedEvent:
16
+ """Single decoded log event. Immutable.
17
+
18
+ Use Mapping[str, Any] for args to ensure immutability.
19
+ The args are wrapped with MappingProxyType at construction.
20
+ """
21
+
22
+ address: str
23
+ event_name: str
24
+ args: Mapping[str, Any] # Immutable - use MappingProxyType when constructing
25
+ log_index: int
26
+ tx_hash: str
27
+ block_number: int
28
+
29
+ @classmethod
30
+ def create(
31
+ cls,
32
+ address: str,
33
+ event_name: str,
34
+ args: dict[str, Any],
35
+ log_index: int,
36
+ tx_hash: str,
37
+ block_number: int,
38
+ ) -> DecodedEvent:
39
+ """Create a DecodedEvent with immutable args.
40
+
41
+ Wraps the args dict in MappingProxyType to ensure immutability.
42
+ """
43
+ return cls(
44
+ address=address,
45
+ event_name=event_name,
46
+ args=MappingProxyType(args),
47
+ log_index=log_index,
48
+ tx_hash=tx_hash,
49
+ block_number=block_number,
50
+ )
51
+
52
+
53
+ def find_event(events: list[DecodedEvent], event_name: str) -> DecodedEvent | None:
54
+ """Find the first event matching the given name.
55
+
56
+ Args:
57
+ events: List of decoded events
58
+ event_name: Name of event to find
59
+
60
+ Returns:
61
+ First matching event, or None if not found
62
+ """
63
+ for event in events:
64
+ if event.event_name == event_name:
65
+ return event
66
+ return None
67
+
68
+
69
+ def events_by_name(events: list[DecodedEvent], event_name: str) -> list[DecodedEvent]:
70
+ """Get all events matching the given name.
71
+
72
+ Args:
73
+ events: List of decoded events
74
+ event_name: Name of events to find
75
+
76
+ Returns:
77
+ List of matching events (may be empty)
78
+ """
79
+ return [e for e in events if e.event_name == event_name]
80
+
81
+
82
+ def events_by_address(events: list[DecodedEvent], address: str) -> list[DecodedEvent]:
83
+ """Get all events emitted by the given address.
84
+
85
+ Args:
86
+ events: List of decoded events
87
+ address: Contract address (case-insensitive comparison)
88
+
89
+ Returns:
90
+ List of matching events (may be empty)
91
+ """
92
+ addr_lower = address.lower()
93
+ return [e for e in events if e.address.lower() == addr_lower]
@@ -0,0 +1,20 @@
1
+ """Startup diagnostic message types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+
9
+ @dataclass
10
+ class StartupMessage:
11
+ """Startup diagnostic message for human-readable display.
12
+
13
+ Used to collect warnings and errors during startup for display
14
+ before the "--- Starting brawny ---" banner.
15
+ """
16
+
17
+ level: Literal["warning", "error"]
18
+ code: str
19
+ message: str
20
+ fix: str | None = None # Actionable fix suggestion