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/model/types.py ADDED
@@ -0,0 +1,483 @@
1
+ """Core data types and dataclasses for brawny."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from typing import Any, Literal
11
+ from uuid import UUID
12
+
13
+ # JSON-serializable value type for metadata
14
+ JSONValue = str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"]
15
+
16
+ # Hook names for type-safe dispatch
17
+ HookName = Literal["on_trigger", "on_success", "on_failure"]
18
+
19
+ from brawny.model.enums import AttemptStatus, IntentStatus, NonceStatus, TxStatus
20
+ from brawny.model.errors import FailureType
21
+
22
+
23
+ def to_wei(value: int | float | str) -> int:
24
+ """Convert a value to wei as an integer.
25
+
26
+ Safely handles:
27
+ - int: returned as-is
28
+ - float: converted if whole number (e.g., 1e18, 10e18)
29
+ - str: parsed as int first, then as float if needed
30
+
31
+ Note on float precision:
32
+ Float64 can only exactly represent integers up to 2^53 (~9e15).
33
+ Wei values (1e18+) exceed this, but common values like 1e18, 10e18,
34
+ 1.5e18 convert correctly. For guaranteed precision with unusual
35
+ values, use integer strings: "10000000000000000001"
36
+
37
+ Raises:
38
+ ValueError: if value has a fractional part (can't have 0.5 wei)
39
+ TypeError: if value is not int, float, or str
40
+
41
+ Examples:
42
+ >>> to_wei(1000000000000000000)
43
+ 1000000000000000000
44
+ >>> to_wei(1e18)
45
+ 1000000000000000000
46
+ >>> to_wei(10e18)
47
+ 10000000000000000000
48
+ >>> to_wei("1000000000000000000")
49
+ 1000000000000000000
50
+ >>> to_wei(1.5e18) # 1.5 * 10^18 is a whole number of wei
51
+ 1500000000000000000
52
+ >>> to_wei(1.5) # Raises ValueError - can't have 0.5 wei
53
+ ValueError: Wei value must be a whole number, got 1.5
54
+ """
55
+ if isinstance(value, int):
56
+ return value
57
+
58
+ if isinstance(value, str):
59
+ value = value.strip()
60
+ if not value:
61
+ return 0
62
+ # Try parsing as int first (handles "123", "-456")
63
+ try:
64
+ return int(value)
65
+ except ValueError:
66
+ pass
67
+ # Try parsing as float (handles "1e18", "1.5e18")
68
+ try:
69
+ value = float(value)
70
+ except ValueError:
71
+ raise ValueError(f"Cannot parse '{value}' as a number")
72
+
73
+ if not isinstance(value, float):
74
+ raise TypeError(f"Expected int, float, or str, got {type(value).__name__}")
75
+
76
+ if not math.isfinite(value):
77
+ raise ValueError(f"Invalid wei value: {value} (must be finite)")
78
+
79
+ # Check for fractional part using modulo
80
+ # This correctly identifies 1.5 as fractional but 1.5e18 as whole
81
+ remainder = value % 1
82
+ if remainder != 0:
83
+ raise ValueError(
84
+ f"Wei value must be a whole number, got {value} "
85
+ f"(fractional part: {remainder})"
86
+ )
87
+
88
+ return int(value)
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class BlockInfo:
93
+ """Information about a specific block."""
94
+
95
+ chain_id: int
96
+ block_number: int
97
+ block_hash: str
98
+ timestamp: int
99
+
100
+ def __post_init__(self) -> None:
101
+ if not self.block_hash.startswith("0x"):
102
+ object.__setattr__(self, "block_hash", f"0x{self.block_hash}")
103
+
104
+
105
+ @dataclass
106
+ class Trigger:
107
+ """Result of a job check indicating action needed.
108
+
109
+ Note: trigger.reason is auto-stamped into intent.metadata["reason"].
110
+ Use intent(..., metadata={}) for per-intent context for alerts.
111
+ """
112
+
113
+ reason: str
114
+ tx_required: bool = True
115
+ idempotency_parts: list[str | int | bytes] = field(default_factory=list)
116
+
117
+
118
+ @dataclass
119
+ class TxIntentSpec:
120
+ """Specification for creating a transaction intent."""
121
+
122
+ signer_address: str
123
+ to_address: str
124
+ data: str | None = None
125
+ value_wei: str = "0"
126
+ gas_limit: int | None = None
127
+ max_fee_per_gas: int | None = None
128
+ max_priority_fee_per_gas: int | None = None
129
+ min_confirmations: int = 1
130
+ deadline_seconds: int | None = None
131
+ metadata: dict[str, JSONValue] | None = None # Per-intent context for alerts
132
+
133
+
134
+ @dataclass
135
+ class TxIntent:
136
+ """Persisted transaction intent record."""
137
+
138
+ intent_id: UUID
139
+ job_id: str
140
+ chain_id: int
141
+ signer_address: str
142
+ idempotency_key: str
143
+ to_address: str
144
+ data: str | None
145
+ value_wei: str
146
+ gas_limit: int | None
147
+ max_fee_per_gas: str | None
148
+ max_priority_fee_per_gas: str | None
149
+ min_confirmations: int
150
+ deadline_ts: datetime | None
151
+ retry_after: datetime | None
152
+ status: IntentStatus
153
+ claim_token: str | None
154
+ claimed_at: datetime | None
155
+ created_at: datetime
156
+ updated_at: datetime
157
+ retry_count: int = 0
158
+
159
+ # Broadcast binding (set on first successful broadcast)
160
+ # These fields preserve the privacy invariant: retries use the SAME endpoints
161
+ broadcast_group: str | None = None
162
+ broadcast_endpoints_json: str | None = None
163
+
164
+ # Per-intent context for alerts (parsed dict, not JSON string)
165
+ metadata: dict[str, JSONValue] = field(default_factory=dict)
166
+
167
+
168
+ @dataclass
169
+ class GasParams:
170
+ """Gas parameters for a transaction."""
171
+
172
+ gas_limit: int
173
+ max_fee_per_gas: int
174
+ max_priority_fee_per_gas: int
175
+
176
+ def __post_init__(self) -> None:
177
+ """Validate gas parameters are non-negative."""
178
+ def _coerce_int(value: int | float | str) -> int:
179
+ if isinstance(value, int):
180
+ return value
181
+ if isinstance(value, float):
182
+ return int(value)
183
+ if isinstance(value, str):
184
+ try:
185
+ return int(value)
186
+ except ValueError:
187
+ from decimal import Decimal
188
+
189
+ return int(Decimal(value))
190
+ return int(value)
191
+
192
+ self.gas_limit = _coerce_int(self.gas_limit)
193
+ self.max_fee_per_gas = _coerce_int(self.max_fee_per_gas)
194
+ self.max_priority_fee_per_gas = _coerce_int(self.max_priority_fee_per_gas)
195
+ if self.gas_limit < 0:
196
+ raise ValueError(f"gas_limit must be non-negative, got {self.gas_limit}")
197
+ if self.max_fee_per_gas < 0:
198
+ raise ValueError(f"max_fee_per_gas must be non-negative, got {self.max_fee_per_gas}")
199
+ if self.max_priority_fee_per_gas < 0:
200
+ raise ValueError(f"max_priority_fee_per_gas must be non-negative, got {self.max_priority_fee_per_gas}")
201
+
202
+ def to_json(self) -> str:
203
+ """Serialize to JSON string."""
204
+ return json.dumps({
205
+ "gas_limit": self.gas_limit,
206
+ "max_fee_per_gas": str(self.max_fee_per_gas),
207
+ "max_priority_fee_per_gas": str(self.max_priority_fee_per_gas),
208
+ })
209
+
210
+ @classmethod
211
+ def from_json(cls, data: str) -> GasParams:
212
+ """Deserialize from JSON string."""
213
+ parsed = json.loads(data)
214
+ return cls(
215
+ gas_limit=parsed["gas_limit"],
216
+ max_fee_per_gas=parsed["max_fee_per_gas"],
217
+ max_priority_fee_per_gas=parsed["max_priority_fee_per_gas"],
218
+ )
219
+
220
+
221
+ @dataclass
222
+ class TxAttempt:
223
+ """Persisted transaction attempt record."""
224
+
225
+ attempt_id: UUID
226
+ intent_id: UUID
227
+ nonce: int
228
+ tx_hash: str | None
229
+ gas_params: GasParams
230
+ status: AttemptStatus
231
+ error_code: str | None
232
+ error_detail: str | None
233
+ replaces_attempt_id: UUID | None
234
+ broadcast_block: int | None
235
+ broadcast_at: datetime | None
236
+ included_block: int | None
237
+ created_at: datetime
238
+ updated_at: datetime
239
+
240
+ # Audit trail (which group and endpoint were used for this attempt)
241
+ broadcast_group: str | None = None
242
+ endpoint_url: str | None = None
243
+
244
+
245
+ @dataclass
246
+ class BroadcastInfo:
247
+ """Broadcast binding information (privacy invariant).
248
+
249
+ Preserves which RPC group/endpoints were used for first broadcast.
250
+ Retries MUST use the same endpoints to prevent privacy leaks.
251
+ """
252
+
253
+ group: str | None
254
+ endpoints: list[str] | None
255
+
256
+ def to_json(self) -> str:
257
+ """Serialize to JSON string."""
258
+ return json.dumps({
259
+ "group": self.group,
260
+ "endpoints": self.endpoints,
261
+ })
262
+
263
+ @classmethod
264
+ def from_json(cls, data: str | None) -> "BroadcastInfo | None":
265
+ """Deserialize from JSON string."""
266
+ if data is None:
267
+ return None
268
+ parsed = json.loads(data)
269
+ return cls(
270
+ group=parsed.get("group"),
271
+ endpoints=parsed.get("endpoints"),
272
+ )
273
+
274
+
275
+ @dataclass
276
+ class TxHashRecord:
277
+ """Record of a single broadcast attempt, stored in tx_hash_history JSON.
278
+
279
+ This is append-only archival data for debugging and postmortems.
280
+ NEVER query this in normal flows.
281
+ """
282
+
283
+ tx_hash: str
284
+ nonce: int
285
+ broadcast_at: str # ISO timestamp
286
+ broadcast_block: int | None
287
+ gas_limit: int
288
+ max_fee_per_gas: int
289
+ max_priority_fee_per_gas: int
290
+ reason: str # "initial", "replacement", "fee_bump"
291
+ outcome: str | None = None # "confirmed", "replaced", "failed", None (pending)
292
+
293
+ def to_dict(self) -> dict[str, Any]:
294
+ """Convert to dict for JSON serialization."""
295
+ return {
296
+ "tx_hash": self.tx_hash,
297
+ "nonce": self.nonce,
298
+ "broadcast_at": self.broadcast_at,
299
+ "broadcast_block": self.broadcast_block,
300
+ "gas_limit": self.gas_limit,
301
+ "max_fee_per_gas": self.max_fee_per_gas,
302
+ "max_priority_fee_per_gas": self.max_priority_fee_per_gas,
303
+ "reason": self.reason,
304
+ "outcome": self.outcome,
305
+ }
306
+
307
+ @classmethod
308
+ def from_dict(cls, data: dict[str, Any]) -> "TxHashRecord":
309
+ """Create from dict."""
310
+ return cls(
311
+ tx_hash=data["tx_hash"],
312
+ nonce=data["nonce"],
313
+ broadcast_at=data["broadcast_at"],
314
+ broadcast_block=data.get("broadcast_block"),
315
+ gas_limit=data["gas_limit"],
316
+ max_fee_per_gas=data["max_fee_per_gas"],
317
+ max_priority_fee_per_gas=data["max_priority_fee_per_gas"],
318
+ reason=data["reason"],
319
+ outcome=data.get("outcome"),
320
+ )
321
+
322
+
323
+ @dataclass
324
+ class Transaction:
325
+ """Single model representing a job transaction through its full lifecycle.
326
+
327
+ IMPORTANT: Transaction is the only durable execution model.
328
+ Do not add attempt tables.
329
+
330
+ This replaces the old TxIntent + TxAttempt dual model with a single
331
+ row per transaction. Replacement history is preserved in tx_hash_history
332
+ JSON field (append-only, for debugging only).
333
+ """
334
+
335
+ # Identity (queryable)
336
+ tx_id: UUID # Primary key
337
+ job_id: str
338
+ chain_id: int
339
+ idempotency_key: str # UNIQUE - prevents duplicates
340
+
341
+ # Transaction payload (immutable after creation)
342
+ signer_address: str
343
+ to_address: str
344
+ data: str | None
345
+ value_wei: str
346
+ min_confirmations: int
347
+ deadline_ts: datetime | None
348
+
349
+ # Current state (queryable)
350
+ status: TxStatus # CREATED → BROADCAST → CONFIRMED/FAILED
351
+ failure_type: FailureType | None
352
+
353
+ # Broadcast state (queryable)
354
+ current_tx_hash: str | None # Active tx hash being monitored
355
+ current_nonce: int | None # Nonce for current broadcast
356
+ replacement_count: int # 0 = first attempt, 1+ = replacements
357
+
358
+ # Worker coordination (queryable)
359
+ claim_token: str | None
360
+ claimed_at: datetime | None
361
+
362
+ # Confirmation (queryable)
363
+ included_block: int | None
364
+ confirmed_at: datetime | None
365
+
366
+ # Audit (queryable)
367
+ created_at: datetime
368
+ updated_at: datetime
369
+
370
+ # --- JSON BLOBS (rarely queried) ---
371
+
372
+ # Gas params for current/next attempt
373
+ gas_params_json: str | None = None # {"gas_limit": N, "max_fee": N, "priority_fee": N}
374
+
375
+ # Broadcast binding (privacy invariant)
376
+ broadcast_info_json: str | None = None # {"group": str, "endpoints": [...]}
377
+
378
+ # Error details (debugging only)
379
+ error_info_json: str | None = None # ErrorInfo as JSON
380
+
381
+ # Broadcast history (append-only, debugging only)
382
+ tx_hash_history: str | None = None # JSON array of TxHashRecord
383
+
384
+ @property
385
+ def gas_params(self) -> GasParams | None:
386
+ """Get gas params from JSON."""
387
+ if self.gas_params_json is None:
388
+ return None
389
+ return GasParams.from_json(self.gas_params_json)
390
+
391
+ @property
392
+ def broadcast_info(self) -> BroadcastInfo | None:
393
+ """Get broadcast info from JSON."""
394
+ return BroadcastInfo.from_json(self.broadcast_info_json)
395
+
396
+ def get_hash_history(self) -> list[TxHashRecord]:
397
+ """Get tx hash history from JSON. For debugging only."""
398
+ if self.tx_hash_history is None:
399
+ return []
400
+ records = json.loads(self.tx_hash_history)
401
+ return [TxHashRecord.from_dict(r) for r in records]
402
+
403
+
404
+ @dataclass
405
+ class NonceReservation:
406
+ """Nonce reservation record."""
407
+
408
+ id: int
409
+ chain_id: int
410
+ signer_address: str
411
+ nonce: int
412
+ status: NonceStatus
413
+ intent_id: UUID | None
414
+ created_at: datetime
415
+ updated_at: datetime
416
+
417
+
418
+ @dataclass
419
+ class SignerState:
420
+ """Signer nonce tracking state."""
421
+
422
+ chain_id: int
423
+ signer_address: str
424
+ next_nonce: int
425
+ last_synced_chain_nonce: int | None
426
+ created_at: datetime
427
+ updated_at: datetime
428
+ gap_started_at: datetime | None = None # When nonce gap blocking started (for alerts)
429
+ alias: str | None = None # Optional human-readable alias
430
+
431
+
432
+ @dataclass
433
+ class JobConfig:
434
+ """Job configuration from database."""
435
+
436
+ job_id: str
437
+ job_name: str
438
+ enabled: bool
439
+ check_interval_blocks: int
440
+ last_checked_block_number: int | None
441
+ last_triggered_block_number: int | None
442
+ created_at: datetime
443
+ updated_at: datetime
444
+
445
+
446
+ def idempotency_key(job_id: str, *parts: str | int | bytes) -> str:
447
+ """
448
+ Generate a stable, deterministic idempotency key.
449
+
450
+ Format: {job_id}:{hash}
451
+
452
+ Rules:
453
+ - bytes are hex-encoded (lowercase, no 0x prefix)
454
+ - ints are decimal string-encoded
455
+ - dicts are sorted by key before serialization
456
+ - hash is SHA256, truncated to 16 hex chars
457
+
458
+ Example:
459
+ >>> idempotency_key("vault_deposit", "0xabc...", 42)
460
+ "vault_deposit:a1b2c3d4e5f6g7h8"
461
+ """
462
+ normalized_parts: list[str] = []
463
+
464
+ for part in parts:
465
+ if isinstance(part, bytes):
466
+ normalized_parts.append(part.hex())
467
+ elif isinstance(part, int):
468
+ normalized_parts.append(str(part))
469
+ elif isinstance(part, dict):
470
+ normalized_parts.append(json.dumps(part, sort_keys=True, separators=(",", ":")))
471
+ elif isinstance(part, str):
472
+ # Remove 0x prefix if present for consistency
473
+ if part.startswith("0x"):
474
+ normalized_parts.append(part[2:].lower())
475
+ else:
476
+ normalized_parts.append(part)
477
+ else:
478
+ normalized_parts.append(str(part))
479
+
480
+ combined = ":".join(normalized_parts)
481
+ hash_bytes = hashlib.sha256(combined.encode("utf-8")).hexdigest()[:16]
482
+
483
+ return f"{job_id}:{hash_bytes}"
@@ -0,0 +1,96 @@
1
+ """Brownie-compatible network module.
2
+
3
+ Usage:
4
+ from brawny import network
5
+
6
+ network.connect("mainnet")
7
+ network.disconnect()
8
+ network.show_active()
9
+ network.is_connected
10
+ network.chain_id
11
+
12
+ NOTE: This is the Brownie-compatible network module that reads from
13
+ ~/.brawny/network-config.yaml. Project-level config.yaml network sections
14
+ are no longer supported.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING
20
+
21
+ from brawny.networks.manager import _get_manager
22
+
23
+ if TYPE_CHECKING:
24
+ from brawny._rpc import RPCManager
25
+
26
+
27
+ class _NetworkProxy:
28
+ """Proxy providing attribute access to NetworkManager singleton.
29
+
30
+ Uses __getattr__ for cleaner forwarding of methods/properties.
31
+ """
32
+
33
+ def connect(self, network_id: str | None = None, launch_rpc: bool = True) -> None:
34
+ """Connect to a network."""
35
+ _get_manager().connect(network_id, launch_rpc)
36
+
37
+ def disconnect(self, kill_rpc: bool = True) -> None:
38
+ """Disconnect from current network."""
39
+ _get_manager().disconnect(kill_rpc)
40
+
41
+ def show_active(self) -> str | None:
42
+ """Get ID of active network."""
43
+ return _get_manager().show_active()
44
+
45
+ @property
46
+ def is_connected(self) -> bool:
47
+ """Check if connected."""
48
+ return _get_manager().is_connected
49
+
50
+ @property
51
+ def chain_id(self) -> int | None:
52
+ """Get current chain ID."""
53
+ return _get_manager().chain_id
54
+
55
+ def list_networks(self) -> dict[str, list[str]]:
56
+ """List all available networks."""
57
+ return _get_manager().list_networks()
58
+
59
+ @property
60
+ def rpc(self) -> RPCManager | None:
61
+ """Get underlying RPCManager."""
62
+ return _get_manager().rpc
63
+
64
+ def rpc_required(self) -> RPCManager:
65
+ """Get RPCManager, raising error if not connected.
66
+
67
+ Use this instead of checking `if network.rpc is None` everywhere.
68
+
69
+ Raises:
70
+ ConnectionError: If not connected to any network
71
+
72
+ Example:
73
+ rpc = network.rpc_required() # Raises if not connected
74
+ block = rpc.get_block_number()
75
+ """
76
+ rpc = _get_manager().rpc
77
+ if rpc is None:
78
+ raise ConnectionError(
79
+ "Not connected to any network. "
80
+ "Call network.connect() first."
81
+ )
82
+ return rpc
83
+
84
+ def __repr__(self) -> str:
85
+ active = self.show_active()
86
+ if active:
87
+ return f"<Network '{active}' (chain_id={self.chain_id})>"
88
+ return "<Network (not connected)>"
89
+
90
+
91
+ network = _NetworkProxy()
92
+
93
+ # Also export config types for advanced usage
94
+ from brawny.networks.config import EnvVarExpansionError, NetworkConfig, load_networks
95
+
96
+ __all__ = ["network", "NetworkConfig", "load_networks", "EnvVarExpansionError"]