uni-exec-engine 0.2.0__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.
- uni_exec_engine-0.2.0.dist-info/METADATA +576 -0
- uni_exec_engine-0.2.0.dist-info/RECORD +80 -0
- uni_exec_engine-0.2.0.dist-info/WHEEL +5 -0
- uni_exec_engine-0.2.0.dist-info/licenses/LICENSE +21 -0
- uni_exec_engine-0.2.0.dist-info/top_level.txt +1 -0
- uniswap_autopilot/__init__.py +3 -0
- uniswap_autopilot/analytics/__init__.py +0 -0
- uniswap_autopilot/analytics/il_calculator.py +525 -0
- uniswap_autopilot/analytics/portfolio.py +234 -0
- uniswap_autopilot/analytics/position.py +433 -0
- uniswap_autopilot/analytics/range_suggest.py +270 -0
- uniswap_autopilot/audit.py +194 -0
- uniswap_autopilot/common/__init__.py +0 -0
- uniswap_autopilot/common/approval_cleanup.py +255 -0
- uniswap_autopilot/common/check_balance.py +64 -0
- uniswap_autopilot/common/common.py +804 -0
- uniswap_autopilot/common/deep_link.py +132 -0
- uniswap_autopilot/common/gas.py +141 -0
- uniswap_autopilot/data/auto_trade_policy.example.json +29 -0
- uniswap_autopilot/data/chains.json +135 -0
- uniswap_autopilot/data/common-token-addresses.json +777 -0
- uniswap_autopilot/execute/__init__.py +0 -0
- uniswap_autopilot/execute/_internal/__init__.py +5 -0
- uniswap_autopilot/execute/_internal/constants.py +15 -0
- uniswap_autopilot/execute/_internal/preflight.py +150 -0
- uniswap_autopilot/execute/_internal/pure_signer.py +182 -0
- uniswap_autopilot/execute/_internal/rpc.py +462 -0
- uniswap_autopilot/execute/_internal/signer.py +298 -0
- uniswap_autopilot/execute/_internal/submit.py +73 -0
- uniswap_autopilot/execute/_internal/tx.py +370 -0
- uniswap_autopilot/execute/broadcast.py +380 -0
- uniswap_autopilot/execute/detect.py +52 -0
- uniswap_autopilot/execute/telegram_confirm.py +272 -0
- uniswap_autopilot/lp/compare_pools.py +338 -0
- uniswap_autopilot/lp/v2/__init__.py +0 -0
- uniswap_autopilot/lp/v2/approve.py +100 -0
- uniswap_autopilot/lp/v2/build_tx.py +226 -0
- uniswap_autopilot/lp/v2/flow.py +204 -0
- uniswap_autopilot/lp/v2/pair.py +135 -0
- uniswap_autopilot/lp/v2/positions.py +177 -0
- uniswap_autopilot/lp/v3/__init__.py +0 -0
- uniswap_autopilot/lp/v3/approve.py +109 -0
- uniswap_autopilot/lp/v3/auto_rebalance.py +282 -0
- uniswap_autopilot/lp/v3/build_tx.py +465 -0
- uniswap_autopilot/lp/v3/compound.py +258 -0
- uniswap_autopilot/lp/v3/flow.py +358 -0
- uniswap_autopilot/lp/v3/pool.py +175 -0
- uniswap_autopilot/lp/v3/position.py +112 -0
- uniswap_autopilot/lp/v3/tick.py +75 -0
- uniswap_autopilot/lp/v4/__init__.py +0 -0
- uniswap_autopilot/lp/v4/approve.py +105 -0
- uniswap_autopilot/lp/v4/build_tx.py +669 -0
- uniswap_autopilot/lp/v4/flow.py +368 -0
- uniswap_autopilot/lp/v4/pool.py +174 -0
- uniswap_autopilot/lp/v4/position.py +185 -0
- uniswap_autopilot/policy.py +371 -0
- uniswap_autopilot/price_feed.py +100 -0
- uniswap_autopilot/py.typed +0 -0
- uniswap_autopilot/search/__init__.py +0 -0
- uniswap_autopilot/search/risk.py +200 -0
- uniswap_autopilot/search/search.py +580 -0
- uniswap_autopilot/state_machine.py +315 -0
- uniswap_autopilot/swap/__init__.py +1 -0
- uniswap_autopilot/swap/deep_link.py +69 -0
- uniswap_autopilot/swap/extensions/__init__.py +2 -0
- uniswap_autopilot/swap/extensions/bridge.py +197 -0
- uniswap_autopilot/swap/extensions/limit_order.py +272 -0
- uniswap_autopilot/swap/extensions/slippage.py +123 -0
- uniswap_autopilot/swap/flow.py +693 -0
- uniswap_autopilot/swap/flow_core/__init__.py +2 -0
- uniswap_autopilot/swap/flow_core/artifacts.py +11 -0
- uniswap_autopilot/swap/flow_core/broadcast.py +50 -0
- uniswap_autopilot/swap/flow_core/diagnostics.py +216 -0
- uniswap_autopilot/swap/flow_core/paper.py +113 -0
- uniswap_autopilot/swap/flow_core/policy.py +142 -0
- uniswap_autopilot/swap/links/__init__.py +2 -0
- uniswap_autopilot/swap/links/deep_link.py +69 -0
- uniswap_autopilot/swap/trading_api/permit.py +41 -0
- uniswap_autopilot/swap/trading_api/quote.py +282 -0
- uniswap_autopilot/swap/trading_api/swap.py +248 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Trade execution state machine — idempotent, resumable, auditable.
|
|
2
|
+
|
|
3
|
+
Provides a lightweight checkpoint mechanism for transaction workflows that
|
|
4
|
+
share the same ``run_id`` used by the audit system. Each state transition is
|
|
5
|
+
recorded to a JSON state file so that a crashed orchestrator can resume from
|
|
6
|
+
the last safe checkpoint without re-signing or re-broadcasting transactions.
|
|
7
|
+
|
|
8
|
+
States are the same event names used by ``audit.py`` so the audit log and the
|
|
9
|
+
state machine speak the same language:
|
|
10
|
+
|
|
11
|
+
INIT → PREFLIGHT → SIGNED → BROADCAST → CONFIRMED
|
|
12
|
+
|
|
13
|
+
Any state can transition to FAILED (terminal). CANCELLED is also terminal.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
import threading
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# ── Constants ───────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
STATE_INIT = "init"
|
|
29
|
+
STATE_PREFLIGHT = "preflight"
|
|
30
|
+
STATE_SIGNED = "signed"
|
|
31
|
+
STATE_BROADCAST = "broadcast"
|
|
32
|
+
STATE_CONFIRMED = "confirmed"
|
|
33
|
+
STATE_CANCELLED = "cancelled"
|
|
34
|
+
STATE_FAILED = "failed"
|
|
35
|
+
|
|
36
|
+
TERMINAL_STATES: frozenset[str] = frozenset({STATE_CONFIRMED, STATE_CANCELLED, STATE_FAILED})
|
|
37
|
+
|
|
38
|
+
# Ordered so the comparison STATE_ORDER.index(a) < STATE_ORDER.index(b) works.
|
|
39
|
+
STATE_ORDER: tuple[str, ...] = (
|
|
40
|
+
STATE_INIT,
|
|
41
|
+
STATE_PREFLIGHT,
|
|
42
|
+
STATE_SIGNED,
|
|
43
|
+
STATE_BROADCAST,
|
|
44
|
+
STATE_CONFIRMED,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Valid transition map — any state can additionally go to FAILED.
|
|
48
|
+
_VALID_TRANSITIONS: dict[str, frozenset[str]] = {
|
|
49
|
+
STATE_INIT: frozenset({STATE_PREFLIGHT, STATE_CANCELLED}),
|
|
50
|
+
STATE_PREFLIGHT: frozenset({STATE_SIGNED, STATE_CANCELLED}),
|
|
51
|
+
STATE_SIGNED: frozenset({STATE_BROADCAST, STATE_CANCELLED}),
|
|
52
|
+
STATE_BROADCAST: frozenset({STATE_CONFIRMED}),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Module-level state ──────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
_write_lock = threading.Lock()
|
|
59
|
+
_DEFAULT_PROJECT = __name__.split(".")[0].replace("_", "-")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _state_dir() -> Path:
|
|
63
|
+
"""Return the directory used for state machine checkpoint files.
|
|
64
|
+
|
|
65
|
+
The directory can be overridden with the ``STAGEFORGE_STATE_DIR``
|
|
66
|
+
environment variable; otherwise ``~/.stageforge/states`` is used.
|
|
67
|
+
"""
|
|
68
|
+
env = os.environ.get("STAGEFORGE_STATE_DIR", "").strip()
|
|
69
|
+
if env:
|
|
70
|
+
return Path(env)
|
|
71
|
+
return Path.home() / ".stageforge" / "states"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _state_path(run_id: str) -> Path:
|
|
75
|
+
"""File-system path for a given *run_id*."""
|
|
76
|
+
return _state_dir() / f"{run_id}.json"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _now_iso() -> str:
|
|
80
|
+
"""ISO-8601 UTC timestamp (without microsecond noise)."""
|
|
81
|
+
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_run_id(explicit: str | None) -> str | None:
|
|
85
|
+
if explicit:
|
|
86
|
+
return explicit
|
|
87
|
+
for name in ("STAGEFORGE_RUN_ID", "AUDIT_RUN_ID", "RUN_ID"):
|
|
88
|
+
value = (os.environ.get(name) or "").strip()
|
|
89
|
+
if value:
|
|
90
|
+
return value
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Action mapping (happy-path states → next action) ─────────────────────
|
|
95
|
+
|
|
96
|
+
_STATE_TO_ACTION: dict[str, str | None] = {
|
|
97
|
+
STATE_INIT: STATE_PREFLIGHT,
|
|
98
|
+
STATE_PREFLIGHT: STATE_SIGNED,
|
|
99
|
+
STATE_SIGNED: STATE_BROADCAST,
|
|
100
|
+
STATE_BROADCAST: STATE_CONFIRMED,
|
|
101
|
+
STATE_CONFIRMED: None,
|
|
102
|
+
STATE_CANCELLED: None,
|
|
103
|
+
STATE_FAILED: None,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Public API ───────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_state(run_id: str) -> dict[str, Any] | None:
|
|
111
|
+
"""Read an existing state checkpoint, or ``None`` if not found."""
|
|
112
|
+
path = _state_path(run_id)
|
|
113
|
+
try:
|
|
114
|
+
with path.open() as fh:
|
|
115
|
+
return json.load(fh) # type: ignore[no-any-return]
|
|
116
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def init_state(
|
|
121
|
+
run_id: str,
|
|
122
|
+
*,
|
|
123
|
+
project: str | None = None,
|
|
124
|
+
payload: dict[str, Any] | None = None,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
"""Create (or return) the initial state record.
|
|
127
|
+
|
|
128
|
+
If a state file already exists for *run_id* it is returned unchanged
|
|
129
|
+
(idempotent). Callers that want to start over must delete the file
|
|
130
|
+
first.
|
|
131
|
+
"""
|
|
132
|
+
existing = load_state(run_id)
|
|
133
|
+
if existing is not None:
|
|
134
|
+
return existing
|
|
135
|
+
|
|
136
|
+
now = _now_iso()
|
|
137
|
+
state: dict[str, Any] = {
|
|
138
|
+
"run_id": run_id,
|
|
139
|
+
"project": project or _DEFAULT_PROJECT,
|
|
140
|
+
"current_state": STATE_INIT,
|
|
141
|
+
"created_at": now,
|
|
142
|
+
"updated_at": now,
|
|
143
|
+
"transition_log": [],
|
|
144
|
+
"payload": payload or {},
|
|
145
|
+
}
|
|
146
|
+
_atomic_write(_state_path(run_id), state)
|
|
147
|
+
return state
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def transition(
|
|
151
|
+
run_id: str,
|
|
152
|
+
target_state: str,
|
|
153
|
+
*,
|
|
154
|
+
payload: dict[str, Any] | None = None,
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""Advance the state machine to *target_state*.
|
|
157
|
+
|
|
158
|
+
Rules:
|
|
159
|
+
|
|
160
|
+
* If *target_state* is FAILED the transition is always accepted
|
|
161
|
+
(any state can fail).
|
|
162
|
+
* If the current state is already >= *target_state* in the happy-path
|
|
163
|
+
order, the call is a no-op (idempotent).
|
|
164
|
+
* If the current state is a terminal state, raises ``RuntimeError``.
|
|
165
|
+
* If the transition is not allowed, raises ``ValueError``.
|
|
166
|
+
|
|
167
|
+
Returns the *updated* state dict.
|
|
168
|
+
"""
|
|
169
|
+
state = init_state(run_id) # ensure state file exists
|
|
170
|
+
current = state["current_state"]
|
|
171
|
+
|
|
172
|
+
# Terminal state — no-op if already in target, error otherwise.
|
|
173
|
+
if current in TERMINAL_STATES:
|
|
174
|
+
if current == target_state:
|
|
175
|
+
return state
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
f"State machine for {run_id} is in terminal state {current!r} — cannot transition to {target_state!r}."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Idempotent — already at or beyond this state.
|
|
181
|
+
if (
|
|
182
|
+
target_state != STATE_FAILED
|
|
183
|
+
and current in STATE_ORDER
|
|
184
|
+
and target_state in STATE_ORDER
|
|
185
|
+
and STATE_ORDER.index(current) >= STATE_ORDER.index(target_state)
|
|
186
|
+
):
|
|
187
|
+
return state
|
|
188
|
+
|
|
189
|
+
# Validate transition.
|
|
190
|
+
if target_state == STATE_FAILED:
|
|
191
|
+
pass # always allowed
|
|
192
|
+
else:
|
|
193
|
+
allowed = _VALID_TRANSITIONS.get(current, frozenset())
|
|
194
|
+
if target_state not in allowed:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"Illegal transition for {run_id}: {current!r} → {target_state!r}. Allowed: {sorted(allowed)}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Perform transition.
|
|
200
|
+
now = _now_iso()
|
|
201
|
+
transition_entry: dict[str, Any] = {
|
|
202
|
+
"from": current,
|
|
203
|
+
"to": target_state,
|
|
204
|
+
"at": now,
|
|
205
|
+
}
|
|
206
|
+
state["current_state"] = target_state
|
|
207
|
+
state["updated_at"] = now
|
|
208
|
+
state["transition_log"].append(transition_entry)
|
|
209
|
+
if payload is not None:
|
|
210
|
+
state["payload"].update(payload)
|
|
211
|
+
|
|
212
|
+
_atomic_write(_state_path(run_id), state)
|
|
213
|
+
return state
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def status(run_id: str) -> dict[str, Any]:
|
|
217
|
+
"""Return a high-level status summary for *run_id*."""
|
|
218
|
+
state = load_state(run_id)
|
|
219
|
+
if state is None:
|
|
220
|
+
return {"run_id": run_id, "found": False, "current_state": None}
|
|
221
|
+
return {
|
|
222
|
+
"run_id": run_id,
|
|
223
|
+
"found": True,
|
|
224
|
+
"project": state.get("project"),
|
|
225
|
+
"current_state": state.get("current_state"),
|
|
226
|
+
"created_at": state.get("created_at"),
|
|
227
|
+
"updated_at": state.get("updated_at"),
|
|
228
|
+
"transition_count": len(state.get("transition_log", [])),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def next_action(run_id: str) -> str | None:
|
|
233
|
+
"""Return the next allowed happy-path action for *run_id*, or None.
|
|
234
|
+
|
|
235
|
+
Callers should check this before executing any side effect (sign, broadcast,
|
|
236
|
+
confirm). If the returned action does not match what the caller plans to do
|
|
237
|
+
the caller must skip that step — it has already been completed in a prior
|
|
238
|
+
run (anti-replay).
|
|
239
|
+
"""
|
|
240
|
+
state = load_state(run_id)
|
|
241
|
+
if state is None:
|
|
242
|
+
return STATE_PREFLIGHT # fresh run — start at preflight
|
|
243
|
+
return _STATE_TO_ACTION.get(state.get("current_state", STATE_INIT))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def resume(run_id: str) -> str | None:
|
|
247
|
+
"""Return the action that an interrupted run should resume from, or None.
|
|
248
|
+
|
|
249
|
+
``resume()`` is a convenience wrapper around ``next_action()`` with clearer
|
|
250
|
+
semantics for orchestrators.
|
|
251
|
+
|
|
252
|
+
Returns ``None`` when the run is already complete (CONFIRMED / CANCELLED /
|
|
253
|
+
FAILED) or the state file is missing.
|
|
254
|
+
"""
|
|
255
|
+
return next_action(run_id)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def list_runs() -> list[dict[str, Any]]:
|
|
259
|
+
"""Return status summaries for all persisted runs."""
|
|
260
|
+
sd = _state_dir()
|
|
261
|
+
results: list[dict[str, Any]] = []
|
|
262
|
+
if not sd.is_dir():
|
|
263
|
+
return results
|
|
264
|
+
for p in sorted(sd.glob("*.json")):
|
|
265
|
+
run_id = p.stem
|
|
266
|
+
s = load_state(run_id)
|
|
267
|
+
if s is not None:
|
|
268
|
+
results.append(
|
|
269
|
+
{
|
|
270
|
+
"run_id": run_id,
|
|
271
|
+
"project": s.get("project"),
|
|
272
|
+
"current_state": s.get("current_state"),
|
|
273
|
+
"updated_at": s.get("updated_at"),
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
return results
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ── Internal helpers ─────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _atomic_write(path: Path, data: dict[str, Any]) -> None:
|
|
283
|
+
"""Write *data* to *path* atomically (write-tmp + rename)."""
|
|
284
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
with _write_lock:
|
|
286
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
287
|
+
try:
|
|
288
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
289
|
+
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
290
|
+
os.replace(tmp, path)
|
|
291
|
+
except Exception:
|
|
292
|
+
try:
|
|
293
|
+
os.unlink(tmp)
|
|
294
|
+
except OSError:
|
|
295
|
+
pass
|
|
296
|
+
raise
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
__all__ = [
|
|
300
|
+
"STATE_BROADCAST",
|
|
301
|
+
"STATE_CANCELLED",
|
|
302
|
+
"STATE_CONFIRMED",
|
|
303
|
+
"STATE_FAILED",
|
|
304
|
+
"STATE_INIT",
|
|
305
|
+
"STATE_PREFLIGHT",
|
|
306
|
+
"STATE_SIGNED",
|
|
307
|
+
"TERMINAL_STATES",
|
|
308
|
+
"init_state",
|
|
309
|
+
"list_runs",
|
|
310
|
+
"load_state",
|
|
311
|
+
"next_action",
|
|
312
|
+
"resume",
|
|
313
|
+
"status",
|
|
314
|
+
"transition",
|
|
315
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Swap modules: trading API, flow execution, and extensions."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from uniswap_autopilot.common.common import (
|
|
8
|
+
add_common_arguments,
|
|
9
|
+
build_swap_link,
|
|
10
|
+
dump_json,
|
|
11
|
+
normalize_chain,
|
|
12
|
+
override_decimals,
|
|
13
|
+
parse_amount,
|
|
14
|
+
resolve_token,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_swap_link_response(
|
|
19
|
+
chain_name: str,
|
|
20
|
+
token_in_name: str,
|
|
21
|
+
token_out_name: str,
|
|
22
|
+
amount_value: str,
|
|
23
|
+
field: str = "INPUT",
|
|
24
|
+
token_in_decimals: int | None = None,
|
|
25
|
+
token_out_decimals: int | None = None,
|
|
26
|
+
) -> dict[str, object]:
|
|
27
|
+
chain = normalize_chain(chain_name)
|
|
28
|
+
amount = parse_amount(amount_value)
|
|
29
|
+
token_in = override_decimals(resolve_token(chain, token_in_name), token_in_decimals)
|
|
30
|
+
token_out = override_decimals(resolve_token(chain, token_out_name), token_out_decimals)
|
|
31
|
+
human_amount = format(amount, "f")
|
|
32
|
+
link = build_swap_link(chain, token_in, token_out, human_amount, field)
|
|
33
|
+
return {
|
|
34
|
+
"action": "build_swap_link",
|
|
35
|
+
"chain": {"key": chain.key, "chainId": chain.chain_id},
|
|
36
|
+
"tokenIn": token_in,
|
|
37
|
+
"tokenOut": token_out,
|
|
38
|
+
"amount": human_amount,
|
|
39
|
+
"field": field,
|
|
40
|
+
"deepLink": link,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> None:
|
|
45
|
+
parser = argparse.ArgumentParser(description="生成 Uniswap swap deep link")
|
|
46
|
+
add_common_arguments(parser)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--field",
|
|
49
|
+
choices=["INPUT", "OUTPUT"],
|
|
50
|
+
default="INPUT",
|
|
51
|
+
help="value 对应输入还是输出字段",
|
|
52
|
+
)
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
|
|
55
|
+
dump_json(
|
|
56
|
+
build_swap_link_response(
|
|
57
|
+
chain_name=args.chain,
|
|
58
|
+
token_in_name=args.token_in,
|
|
59
|
+
token_out_name=args.token_out,
|
|
60
|
+
amount_value=args.amount,
|
|
61
|
+
field=args.field,
|
|
62
|
+
token_in_decimals=args.token_in_decimals,
|
|
63
|
+
token_out_decimals=args.token_out_decimals,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
main()
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from uniswap_autopilot.common.common import (
|
|
10
|
+
dump_json,
|
|
11
|
+
load_local_env,
|
|
12
|
+
normalize_chain,
|
|
13
|
+
override_decimals,
|
|
14
|
+
parse_amount,
|
|
15
|
+
post_json,
|
|
16
|
+
require_api_key,
|
|
17
|
+
resolve_api_token,
|
|
18
|
+
resolve_token,
|
|
19
|
+
resolve_wallet_address,
|
|
20
|
+
decimal_to_base_units,
|
|
21
|
+
native_currency_address,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_cross_chain_quote_payload(
|
|
26
|
+
wallet: str | None,
|
|
27
|
+
src_chain_id: int,
|
|
28
|
+
dst_chain_id: int,
|
|
29
|
+
api_token_in: dict[str, Any],
|
|
30
|
+
api_token_out: dict[str, Any],
|
|
31
|
+
base_amount: str,
|
|
32
|
+
swap_type: str,
|
|
33
|
+
slippage: float,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
return {
|
|
36
|
+
"swapper": wallet or native_currency_address(),
|
|
37
|
+
"tokenIn": api_token_in["address"],
|
|
38
|
+
"tokenOut": api_token_out["address"],
|
|
39
|
+
"tokenInChainId": src_chain_id,
|
|
40
|
+
"tokenOutChainId": dst_chain_id,
|
|
41
|
+
"amount": base_amount,
|
|
42
|
+
"type": swap_type,
|
|
43
|
+
"slippageTolerance": slippage,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def prepare_cross_chain_quote(
|
|
48
|
+
src_chain_name: str,
|
|
49
|
+
dst_chain_name: str,
|
|
50
|
+
token_in_name: str,
|
|
51
|
+
token_out_name: str,
|
|
52
|
+
amount_value: str,
|
|
53
|
+
wallet: str | None = None,
|
|
54
|
+
token_in_decimals: int | None = None,
|
|
55
|
+
token_out_decimals: int | None = None,
|
|
56
|
+
swap_type: str = "EXACT_INPUT",
|
|
57
|
+
slippage: float = 0.5,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
src_chain = normalize_chain(src_chain_name)
|
|
60
|
+
dst_chain = normalize_chain(dst_chain_name)
|
|
61
|
+
amount = parse_amount(amount_value)
|
|
62
|
+
token_in = override_decimals(resolve_token(src_chain, token_in_name), token_in_decimals)
|
|
63
|
+
token_out = override_decimals(resolve_token(dst_chain, token_out_name), token_out_decimals)
|
|
64
|
+
api_token_in = resolve_api_token(src_chain, token_in)
|
|
65
|
+
api_token_out = resolve_api_token(dst_chain, token_out)
|
|
66
|
+
validated_wallet = resolve_wallet_address(wallet, "wallet")
|
|
67
|
+
base_amount = decimal_to_base_units(amount, api_token_in["decimals"])
|
|
68
|
+
|
|
69
|
+
payload = build_cross_chain_quote_payload(
|
|
70
|
+
wallet=validated_wallet,
|
|
71
|
+
src_chain_id=src_chain.chain_id,
|
|
72
|
+
dst_chain_id=dst_chain.chain_id,
|
|
73
|
+
api_token_in=api_token_in,
|
|
74
|
+
api_token_out=api_token_out,
|
|
75
|
+
base_amount=base_amount,
|
|
76
|
+
swap_type=swap_type,
|
|
77
|
+
slippage=slippage,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
response: dict[str, Any] = {
|
|
81
|
+
"action": "cross_chain_quote",
|
|
82
|
+
"srcChain": {"key": src_chain.key, "chainId": src_chain.chain_id},
|
|
83
|
+
"dstChain": {"key": dst_chain.key, "chainId": dst_chain.chain_id},
|
|
84
|
+
"tokenIn": token_in,
|
|
85
|
+
"tokenOut": token_out,
|
|
86
|
+
"humanAmount": format(amount, "f"),
|
|
87
|
+
"baseAmount": base_amount,
|
|
88
|
+
"requestPayload": payload,
|
|
89
|
+
}
|
|
90
|
+
return response
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fetch_cross_chain_quote(response: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
api_key = require_api_key()
|
|
95
|
+
raw = post_json("quote", response["requestPayload"], api_key)
|
|
96
|
+
response["rawQuote"] = raw
|
|
97
|
+
return response
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def verify_bridge_arrival(
|
|
101
|
+
dst_chain_name: str,
|
|
102
|
+
token_address: str,
|
|
103
|
+
wallet: str,
|
|
104
|
+
expected_amount_base: str,
|
|
105
|
+
timeout: int = 600,
|
|
106
|
+
poll_interval: int = 30,
|
|
107
|
+
rpc_url: str | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
from uniswap_autopilot.common.common import PUBLIC_RPC_URLS
|
|
110
|
+
from uniswap_autopilot.execute._internal.rpc import query_erc20_balance, query_native_balance, resolve_rpc_url
|
|
111
|
+
|
|
112
|
+
dst_chain = normalize_chain(dst_chain_name)
|
|
113
|
+
rpc = rpc_url or PUBLIC_RPC_URLS.get(dst_chain.key) or resolve_rpc_url(None, dst_chain.chain_id)[0]
|
|
114
|
+
if not rpc:
|
|
115
|
+
raise RuntimeError(f"RPC URL not configured for {dst_chain_name}")
|
|
116
|
+
|
|
117
|
+
expected = int(expected_amount_base)
|
|
118
|
+
start = time.time()
|
|
119
|
+
polls = 0
|
|
120
|
+
balance = 0
|
|
121
|
+
|
|
122
|
+
while time.time() - start < timeout:
|
|
123
|
+
polls += 1
|
|
124
|
+
try:
|
|
125
|
+
if token_address in ("NATIVE", native_currency_address()):
|
|
126
|
+
balance = query_native_balance(wallet, rpc)
|
|
127
|
+
else:
|
|
128
|
+
balance = query_erc20_balance(wallet, token_address, rpc)
|
|
129
|
+
if balance >= expected:
|
|
130
|
+
return {
|
|
131
|
+
"arrived": True,
|
|
132
|
+
"balance": str(balance),
|
|
133
|
+
"expected": str(expected),
|
|
134
|
+
"elapsedSeconds": int(time.time() - start),
|
|
135
|
+
"polls": polls,
|
|
136
|
+
}
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
time.sleep(poll_interval)
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"arrived": False,
|
|
143
|
+
"balance": str(balance),
|
|
144
|
+
"expected": str(expected),
|
|
145
|
+
"elapsedSeconds": int(time.time() - start),
|
|
146
|
+
"polls": polls,
|
|
147
|
+
"timeout": True,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main() -> None:
|
|
152
|
+
parser = argparse.ArgumentParser(description="Cross-chain swap via Uniswap Trading API")
|
|
153
|
+
sub = parser.add_subparsers(dest="command")
|
|
154
|
+
|
|
155
|
+
q = sub.add_parser("quote", help="Get cross-chain quote")
|
|
156
|
+
q.add_argument("--src-chain", required=True, help="Source chain")
|
|
157
|
+
q.add_argument("--dst-chain", required=True, help="Destination chain")
|
|
158
|
+
q.add_argument("--token-in", required=True)
|
|
159
|
+
q.add_argument("--token-out", required=True)
|
|
160
|
+
q.add_argument("--amount", required=True)
|
|
161
|
+
q.add_argument("--wallet")
|
|
162
|
+
q.add_argument("--slippage", type=float, default=0.5)
|
|
163
|
+
q.add_argument("--request-only", action="store_true", help="Only print payload")
|
|
164
|
+
|
|
165
|
+
v = sub.add_parser("verify", help="Verify bridge arrival")
|
|
166
|
+
v.add_argument("--dst-chain", required=True)
|
|
167
|
+
v.add_argument("--token-address", required=True)
|
|
168
|
+
v.add_argument("--wallet", required=True)
|
|
169
|
+
v.add_argument("--expected-amount", required=True, help="Expected amount in base units")
|
|
170
|
+
v.add_argument("--timeout", type=int, default=600)
|
|
171
|
+
v.add_argument("--poll-interval", type=int, default=30)
|
|
172
|
+
|
|
173
|
+
args = parser.parse_args()
|
|
174
|
+
load_local_env()
|
|
175
|
+
|
|
176
|
+
if args.command == "quote":
|
|
177
|
+
result = prepare_cross_chain_quote(
|
|
178
|
+
args.src_chain, args.dst_chain, args.token_in, args.token_out,
|
|
179
|
+
args.amount, args.wallet, slippage=args.slippage,
|
|
180
|
+
)
|
|
181
|
+
if not args.request_only:
|
|
182
|
+
result = fetch_cross_chain_quote(result)
|
|
183
|
+
dump_json(result)
|
|
184
|
+
elif args.command == "verify":
|
|
185
|
+
result = verify_bridge_arrival(
|
|
186
|
+
args.dst_chain, args.token_address, args.wallet,
|
|
187
|
+
args.expected_amount, args.timeout, args.poll_interval,
|
|
188
|
+
)
|
|
189
|
+
status = "ARRIVED" if result["arrived"] else "TIMEOUT"
|
|
190
|
+
print(f"Bridge status: {status} ({result['polls']} polls, {result['elapsedSeconds']}s)")
|
|
191
|
+
dump_json(result)
|
|
192
|
+
else:
|
|
193
|
+
parser.print_help()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
main()
|