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.
- brawny/__init__.py +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- 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"]
|