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