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,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Telegram confirmation helper for Uniswap trade execution.
|
|
4
|
+
|
|
5
|
+
Sends trade details to Telegram with inline Approve/Reject buttons,
|
|
6
|
+
then polls for callback response. Pure stdlib — no curl dependency.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from uniswap_autopilot.execute.telegram_confirm import request_trade_confirmation
|
|
10
|
+
if not request_trade_confirmation(trade_details):
|
|
11
|
+
sys.exit(0)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.error import HTTPError, URLError
|
|
23
|
+
from urllib.request import Request, urlopen
|
|
24
|
+
|
|
25
|
+
# ── config ──────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read_config_file(config_path: str | None = None) -> tuple[str, str, list[str]]:
|
|
29
|
+
"""Return (bot_token, chat_id, allowed_user_ids) from a JSON config file.
|
|
30
|
+
|
|
31
|
+
Config is expected to be a dict with optional nested keys, e.g.
|
|
32
|
+
{"botToken": "...", "chatId": "...", "allowFrom": [...]} or
|
|
33
|
+
{"channels": {"telegram": {"botToken": "...", "allowFrom": [...]}}}
|
|
34
|
+
"""
|
|
35
|
+
path = Path(config_path or os.environ.get("TELEGRAM_CONFIG_PATH", ""))
|
|
36
|
+
if not path.is_file():
|
|
37
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
38
|
+
|
|
39
|
+
with open(path) as f:
|
|
40
|
+
cfg = json.load(f)
|
|
41
|
+
|
|
42
|
+
bot_token = cfg.get("botToken", "")
|
|
43
|
+
chat_id = cfg.get("chatId", "")
|
|
44
|
+
allow_from = cfg.get("allowFrom") or []
|
|
45
|
+
|
|
46
|
+
if not bot_token:
|
|
47
|
+
tg = cfg.get("channels", {}).get("telegram", {})
|
|
48
|
+
bot_token = tg.get("botToken", "")
|
|
49
|
+
if not chat_id:
|
|
50
|
+
allow = tg.get("allowFrom", [])
|
|
51
|
+
chat_id = str(allow[-1]) if allow else ""
|
|
52
|
+
if not allow_from:
|
|
53
|
+
allow_from = tg.get("allowFrom") or []
|
|
54
|
+
|
|
55
|
+
if not bot_token:
|
|
56
|
+
raise RuntimeError("Telegram botToken not found in config file")
|
|
57
|
+
return bot_token, chat_id, [str(x) for x in allow_from]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
BOT_TOKEN, CHAT_ID = "", ""
|
|
61
|
+
ALLOWED_USER_IDS: list[str] = []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ensure_config():
|
|
65
|
+
"""Lazily load config on first use to avoid import-time failure."""
|
|
66
|
+
global BOT_TOKEN, CHAT_ID, ALLOWED_USER_IDS
|
|
67
|
+
if BOT_TOKEN:
|
|
68
|
+
return
|
|
69
|
+
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
70
|
+
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
|
71
|
+
env_allow = os.environ.get("TELEGRAM_ALLOWED_USER_IDS", "")
|
|
72
|
+
if env_allow:
|
|
73
|
+
ALLOWED_USER_IDS = [x.strip() for x in env_allow.split(",") if x.strip()]
|
|
74
|
+
if BOT_TOKEN and CHAT_ID and ALLOWED_USER_IDS:
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
token, chat, allow = _read_config_file()
|
|
78
|
+
BOT_TOKEN = BOT_TOKEN or token
|
|
79
|
+
CHAT_ID = CHAT_ID or chat
|
|
80
|
+
if not ALLOWED_USER_IDS:
|
|
81
|
+
ALLOWED_USER_IDS = allow
|
|
82
|
+
except (FileNotFoundError, RuntimeError):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _state_file(confirmation_id: str | None = None) -> Path:
|
|
87
|
+
"""Cross-platform state file path.
|
|
88
|
+
|
|
89
|
+
The previous implementation used a single shared file for ALL
|
|
90
|
+
confirmation requests, which made it impossible to run concurrent trade
|
|
91
|
+
flows safely (one would overwrite another's state). We now namespace the
|
|
92
|
+
file by ``confirmation_id`` so each request owns its own state slot.
|
|
93
|
+
"""
|
|
94
|
+
name = "uniswap_trade_confirmation_state"
|
|
95
|
+
if confirmation_id:
|
|
96
|
+
# Strip any characters that would be problematic on disk.
|
|
97
|
+
safe = "".join(c for c in confirmation_id if c.isalnum() or c in ("_", "-"))
|
|
98
|
+
name = f"{name}_{safe}"
|
|
99
|
+
return Path(tempfile.gettempdir()) / f"{name}.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── helpers ─────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _tg_api(method: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
106
|
+
"""Call Telegram Bot API via urllib (no curl dependency)."""
|
|
107
|
+
_ensure_config()
|
|
108
|
+
if not BOT_TOKEN:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"Telegram bot token not configured. "
|
|
111
|
+
"Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars, "
|
|
112
|
+
"or provide a config file via TELEGRAM_CONFIG_PATH"
|
|
113
|
+
)
|
|
114
|
+
url = f"https://api.telegram.org/bot{BOT_TOKEN}/{method}"
|
|
115
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
116
|
+
req = Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
117
|
+
try:
|
|
118
|
+
with urlopen(req, timeout=15) as resp:
|
|
119
|
+
return json.loads(resp.read())
|
|
120
|
+
except (HTTPError, URLError) as e:
|
|
121
|
+
return {"ok": False, "error": str(e)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _poll_updates(offset: int | None = None, timeout: int = 2) -> list[dict]:
|
|
125
|
+
"""Short-poll getUpdates for callback_query only."""
|
|
126
|
+
payload: dict[str, Any] = {
|
|
127
|
+
"allowed_updates": ["callback_query"],
|
|
128
|
+
"timeout": timeout,
|
|
129
|
+
}
|
|
130
|
+
if offset is not None:
|
|
131
|
+
payload["offset"] = offset
|
|
132
|
+
resp = _tg_api("getUpdates", payload)
|
|
133
|
+
return resp.get("result", [])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── main API ────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def request_trade_confirmation(
|
|
140
|
+
trade_details: dict[str, Any],
|
|
141
|
+
timeout_seconds: int = 300,
|
|
142
|
+
) -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Send trade confirmation to Telegram and wait for user response.
|
|
145
|
+
|
|
146
|
+
Returns True if approved, False if rejected or timeout.
|
|
147
|
+
"""
|
|
148
|
+
_ensure_config()
|
|
149
|
+
confirmation_id = f"uni_{int(time.time()) % 100000:05d}"
|
|
150
|
+
|
|
151
|
+
lines = [
|
|
152
|
+
"🔍 *Uniswap Trade Confirmation*",
|
|
153
|
+
"",
|
|
154
|
+
f"📍 Chain: `{trade_details.get('chain', '?')}`",
|
|
155
|
+
f"🔄 {trade_details.get('tokenIn', '?')} → {trade_details.get('tokenOut', '?')}",
|
|
156
|
+
f"💰 {trade_details.get('amountIn', '?')} → {trade_details.get('amountOut', '?')}",
|
|
157
|
+
"",
|
|
158
|
+
f"⏳ Timeout: {timeout_seconds // 60} min",
|
|
159
|
+
f"🆔 `{confirmation_id}`",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
payload = {
|
|
163
|
+
"chat_id": CHAT_ID,
|
|
164
|
+
"text": "\n".join(lines),
|
|
165
|
+
"parse_mode": "Markdown",
|
|
166
|
+
"reply_markup": {
|
|
167
|
+
"inline_keyboard": [[
|
|
168
|
+
{"text": "✅ Approve", "callback_data": f"uap_{confirmation_id}"},
|
|
169
|
+
{"text": "❌ Reject", "callback_data": f"urj_{confirmation_id}"},
|
|
170
|
+
]]
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
resp = _tg_api("sendMessage", payload)
|
|
175
|
+
if not resp.get("ok"):
|
|
176
|
+
print(f"⚠️ Telegram sendMessage failed: {resp}")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
msg_id = resp["result"]["message_id"]
|
|
180
|
+
print(f"📤 Confirmation sent (msg_id={msg_id}, id={confirmation_id})")
|
|
181
|
+
|
|
182
|
+
state = {
|
|
183
|
+
"confirmation_id": confirmation_id,
|
|
184
|
+
"status": "pending",
|
|
185
|
+
"created_at": time.time(),
|
|
186
|
+
}
|
|
187
|
+
state_path = _state_file(confirmation_id)
|
|
188
|
+
state_path.write_text(json.dumps(state))
|
|
189
|
+
|
|
190
|
+
deadline = time.time() + timeout_seconds
|
|
191
|
+
last_offset: int | None = None
|
|
192
|
+
|
|
193
|
+
while time.time() < deadline:
|
|
194
|
+
try:
|
|
195
|
+
updates = _poll_updates(
|
|
196
|
+
offset=last_offset,
|
|
197
|
+
timeout=min(5, int(deadline - time.time())),
|
|
198
|
+
)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
print(f"⚠️ Poll error: {e}, retrying...")
|
|
201
|
+
time.sleep(2)
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
for upd in updates:
|
|
205
|
+
last_offset = upd["update_id"] + 1
|
|
206
|
+
cb = upd.get("callback_query")
|
|
207
|
+
if not cb:
|
|
208
|
+
continue
|
|
209
|
+
from_user = cb.get("from", {}) if isinstance(cb, dict) else {}
|
|
210
|
+
from_id = str(from_user.get("id", "")).strip()
|
|
211
|
+
if ALLOWED_USER_IDS and from_id not in ALLOWED_USER_IDS:
|
|
212
|
+
_tg_api("answerCallbackQuery", {
|
|
213
|
+
"callback_query_id": cb.get("id", ""),
|
|
214
|
+
"text": "Not authorized",
|
|
215
|
+
"show_alert": True,
|
|
216
|
+
})
|
|
217
|
+
continue
|
|
218
|
+
data = cb.get("data", "")
|
|
219
|
+
if data in (f"uap_{confirmation_id}", f"urj_{confirmation_id}"):
|
|
220
|
+
is_approved = data.startswith("uap_")
|
|
221
|
+
decision = "✅ Approved" if is_approved else "❌ Rejected"
|
|
222
|
+
state["status"] = "approved" if is_approved else "rejected"
|
|
223
|
+
state["resolved_at"] = time.time()
|
|
224
|
+
state_path.write_text(json.dumps(state))
|
|
225
|
+
_tg_api("editMessageReplyMarkup", {
|
|
226
|
+
"chat_id": CHAT_ID,
|
|
227
|
+
"message_id": msg_id,
|
|
228
|
+
})
|
|
229
|
+
_tg_api("editMessageText", {
|
|
230
|
+
"chat_id": CHAT_ID,
|
|
231
|
+
"message_id": msg_id,
|
|
232
|
+
"text": "\n".join(lines) + "\n\n🔒 *Decision: " + decision + "*",
|
|
233
|
+
"parse_mode": "Markdown",
|
|
234
|
+
})
|
|
235
|
+
_tg_api("answerCallbackQuery", {
|
|
236
|
+
"callback_query_id": cb["id"],
|
|
237
|
+
})
|
|
238
|
+
print(f"{decision} by user")
|
|
239
|
+
return is_approved
|
|
240
|
+
|
|
241
|
+
time.sleep(0.5)
|
|
242
|
+
|
|
243
|
+
print("⏰ Confirmation timeout")
|
|
244
|
+
state["status"] = "timeout"
|
|
245
|
+
state_path.write_text(json.dumps(state))
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── CLI test ────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main() -> None:
|
|
253
|
+
import argparse
|
|
254
|
+
parser = argparse.ArgumentParser(description="Test Telegram trade confirmation")
|
|
255
|
+
parser.add_argument("--test", action="store_true")
|
|
256
|
+
args = parser.parse_args()
|
|
257
|
+
|
|
258
|
+
if args.test:
|
|
259
|
+
ok = request_trade_confirmation({
|
|
260
|
+
"chain": "Base",
|
|
261
|
+
"tokenIn": "ETH",
|
|
262
|
+
"tokenOut": "USDC",
|
|
263
|
+
"amountIn": "0.1 ETH",
|
|
264
|
+
"amountOut": "~200 USDC",
|
|
265
|
+
}, timeout_seconds=120)
|
|
266
|
+
print(f"\nResult: {'APPROVED' if ok else 'REJECTED/TIMEOUT'}")
|
|
267
|
+
else:
|
|
268
|
+
print("Use --test to send a test confirmation")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
main()
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from uniswap_autopilot.common.common import (
|
|
12
|
+
V3_FEE_TIERS,
|
|
13
|
+
dump_json,
|
|
14
|
+
load_local_env,
|
|
15
|
+
normalize_chain,
|
|
16
|
+
resolve_token,
|
|
17
|
+
sort_token_addresses,
|
|
18
|
+
)
|
|
19
|
+
from uniswap_autopilot.lp.v3.pool import query_pool_full_info
|
|
20
|
+
from uniswap_autopilot.search.search import _ds_lookup, _fetch_json
|
|
21
|
+
|
|
22
|
+
DEFILLAMA_YIELDS_URL = "https://yields.llama.fi/pools"
|
|
23
|
+
|
|
24
|
+
DEFILLAMA_CHAIN_MAP: dict[str, str] = {
|
|
25
|
+
"avalanche": "avax",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _defillama_chain_name(chain_key: str) -> str:
|
|
30
|
+
mapped = DEFILLAMA_CHAIN_MAP.get(chain_key, chain_key)
|
|
31
|
+
return mapped.capitalize()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch_defillama_yields(chain: str) -> dict[str, dict]:
|
|
35
|
+
chain_key = chain.strip().lower()
|
|
36
|
+
dl_chain = _defillama_chain_name(chain_key)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
data = _fetch_json(DEFILLAMA_YIELDS_URL, timeout=30)
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
print(f" [warn] DefiLlama yields fetch failed: {exc}", file=sys.stderr)
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
pools = data.get("data", [])
|
|
45
|
+
result: dict[str, dict] = {}
|
|
46
|
+
for pool in pools:
|
|
47
|
+
if pool.get("project") != "uniswap-v3":
|
|
48
|
+
continue
|
|
49
|
+
if (pool.get("chain") or "").lower() != dl_chain.lower():
|
|
50
|
+
continue
|
|
51
|
+
pool_addr = (pool.get("pool") or "").lower()
|
|
52
|
+
if not pool_addr:
|
|
53
|
+
continue
|
|
54
|
+
result[pool_addr] = {
|
|
55
|
+
"apy": pool.get("apy"),
|
|
56
|
+
"apyBase": pool.get("apyBase"),
|
|
57
|
+
"tvlUsd": pool.get("tvlUsd"),
|
|
58
|
+
"volumeUsd1d": pool.get("volumeUsd1d"),
|
|
59
|
+
}
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fetch_dexscreener_pair(
|
|
64
|
+
chain: str, token0_addr: str, token1_addr: str
|
|
65
|
+
) -> dict | None:
|
|
66
|
+
try:
|
|
67
|
+
ds = _ds_lookup(chain, token0_addr)
|
|
68
|
+
except Exception:
|
|
69
|
+
ds = None
|
|
70
|
+
if not ds:
|
|
71
|
+
try:
|
|
72
|
+
ds = _ds_lookup(chain, token1_addr)
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
if not ds:
|
|
76
|
+
return None
|
|
77
|
+
return {
|
|
78
|
+
"volume24h": ds.get("volume24h"),
|
|
79
|
+
"liquidityUsd": ds.get("liquidityUsd"),
|
|
80
|
+
"priceUsd": ds.get("priceUsd"),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fmt_usd(val: float | None) -> str:
|
|
85
|
+
if val is None:
|
|
86
|
+
return "-"
|
|
87
|
+
if val >= 1_000_000_000:
|
|
88
|
+
return f"${val / 1_000_000_000:.2f}B"
|
|
89
|
+
if val >= 1_000_000:
|
|
90
|
+
return f"${val / 1_000_000:.2f}M"
|
|
91
|
+
if val >= 1_000:
|
|
92
|
+
return f"${val / 1_000:.1f}K"
|
|
93
|
+
if val > 0:
|
|
94
|
+
return f"${val:.4f}"
|
|
95
|
+
return "-"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fmt_pct(val: float | None) -> str:
|
|
99
|
+
if val is None:
|
|
100
|
+
return "-"
|
|
101
|
+
return f"{val:.2f}%"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _safe_float(val: Any, default: float = 0.0) -> float:
|
|
105
|
+
if val is None:
|
|
106
|
+
return default
|
|
107
|
+
try:
|
|
108
|
+
return float(val)
|
|
109
|
+
except (ValueError, TypeError):
|
|
110
|
+
return default
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def compare_pools_for_pair(
|
|
114
|
+
chain_name: str, token_a: str, token_b: str, rpc_url: str | None
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
chain = normalize_chain(chain_name)
|
|
117
|
+
token_a_info = resolve_token(chain, token_a, rpc_url)
|
|
118
|
+
token_b_info = resolve_token(chain, token_b, rpc_url)
|
|
119
|
+
|
|
120
|
+
def _addr_or_wrapped(info: dict[str, Any]) -> str:
|
|
121
|
+
addr = info["address"]
|
|
122
|
+
if addr != "NATIVE":
|
|
123
|
+
return addr
|
|
124
|
+
wrapped = chain.tokens.get(chain.wrapped_native_symbol.upper())
|
|
125
|
+
if wrapped:
|
|
126
|
+
return wrapped.address
|
|
127
|
+
wtoken = resolve_token(chain, chain.wrapped_native_symbol, rpc_url)
|
|
128
|
+
return wtoken["address"]
|
|
129
|
+
|
|
130
|
+
addr_a = _addr_or_wrapped(token_a_info)
|
|
131
|
+
addr_b = _addr_or_wrapped(token_b_info)
|
|
132
|
+
token0_addr, token1_addr = sort_token_addresses(addr_a, addr_b)
|
|
133
|
+
|
|
134
|
+
pool_results: list[dict[str, Any]] = []
|
|
135
|
+
fee_tiers_sorted = sorted(V3_FEE_TIERS)
|
|
136
|
+
|
|
137
|
+
for fee_tier in fee_tiers_sorted:
|
|
138
|
+
try:
|
|
139
|
+
info = query_pool_full_info(
|
|
140
|
+
chain_name, token_a, token_b, fee_tier, rpc_url
|
|
141
|
+
)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
pool_results.append({
|
|
144
|
+
"feeTier": fee_tier,
|
|
145
|
+
"feePct": f"{fee_tier / 10000:.2f}%",
|
|
146
|
+
"exists": False,
|
|
147
|
+
"error": str(exc),
|
|
148
|
+
})
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
if not info.get("exists"):
|
|
152
|
+
pool_results.append({
|
|
153
|
+
"feeTier": fee_tier,
|
|
154
|
+
"feePct": f"{fee_tier / 10000:.2f}%",
|
|
155
|
+
"poolAddress": None,
|
|
156
|
+
"exists": False,
|
|
157
|
+
})
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
pool_addr = info.get("poolAddress", "").lower()
|
|
161
|
+
pool_results.append({
|
|
162
|
+
"feeTier": fee_tier,
|
|
163
|
+
"feePct": f"{fee_tier / 10000:.2f}%",
|
|
164
|
+
"poolAddress": info.get("poolAddress"),
|
|
165
|
+
"exists": True,
|
|
166
|
+
"currentTick": info.get("currentTick"),
|
|
167
|
+
"currentPrice": info.get("currentPrice"),
|
|
168
|
+
"liquidity": info.get("liquidity"),
|
|
169
|
+
"_addr_lower": pool_addr,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
# Fetch DefiLlama yields for the chain
|
|
173
|
+
dl_yields = fetch_defillama_yields(chain_name)
|
|
174
|
+
|
|
175
|
+
# Fetch DexScreener data for the pair
|
|
176
|
+
ds_data = fetch_dexscreener_pair(chain.key, token0_addr, token1_addr)
|
|
177
|
+
|
|
178
|
+
# Enrich pool results with yield and volume data
|
|
179
|
+
for pr in pool_results:
|
|
180
|
+
if not pr.get("exists"):
|
|
181
|
+
pr["tvlUsd"] = None
|
|
182
|
+
pr["volume24h"] = None
|
|
183
|
+
pr["apy"] = None
|
|
184
|
+
pr["apyBase"] = None
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
addr_lower = pr.get("_addr_lower", "")
|
|
188
|
+
dl = dl_yields.get(addr_lower, {})
|
|
189
|
+
pr["tvlUsd"] = dl.get("tvlUsd")
|
|
190
|
+
pr["volumeUsd1d"] = dl.get("volumeUsd1d")
|
|
191
|
+
pr["apy"] = dl.get("apy")
|
|
192
|
+
pr["apyBase"] = dl.get("apyBase")
|
|
193
|
+
|
|
194
|
+
if ds_data:
|
|
195
|
+
pr["dexVolume24h"] = ds_data.get("volume24h")
|
|
196
|
+
pr["dexLiquidityUsd"] = ds_data.get("liquidityUsd")
|
|
197
|
+
pr["dexPriceUsd"] = ds_data.get("priceUsd")
|
|
198
|
+
else:
|
|
199
|
+
pr["dexVolume24h"] = None
|
|
200
|
+
pr["dexLiquidityUsd"] = None
|
|
201
|
+
pr["dexPriceUsd"] = None
|
|
202
|
+
|
|
203
|
+
# Clean internal key
|
|
204
|
+
pr.pop("_addr_lower", None)
|
|
205
|
+
|
|
206
|
+
# Determine recommendation
|
|
207
|
+
eligible = [pr for pr in pool_results if pr.get("exists")]
|
|
208
|
+
recommendation = None
|
|
209
|
+
if eligible:
|
|
210
|
+
high_tvl = [p for p in eligible if (_safe_float(p.get("tvlUsd")) or _safe_float(p.get("dexLiquidityUsd"))) > 100_000]
|
|
211
|
+
if high_tvl:
|
|
212
|
+
best = max(high_tvl, key=lambda p: _safe_float(p.get("apy")))
|
|
213
|
+
reason = "highest APY"
|
|
214
|
+
else:
|
|
215
|
+
best = max(eligible, key=lambda p: _safe_float(p.get("tvlUsd")) or _safe_float(p.get("dexLiquidityUsd")))
|
|
216
|
+
reason = "highest TVL (no pool above $100K TVL)"
|
|
217
|
+
recommendation = {
|
|
218
|
+
"feeTier": best["feeTier"],
|
|
219
|
+
"feePct": best["feePct"],
|
|
220
|
+
"poolAddress": best["poolAddress"],
|
|
221
|
+
"reason": reason,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
"action": "compare_pools",
|
|
226
|
+
"chain": {"key": chain.key, "chainId": chain.chain_id},
|
|
227
|
+
"tokenA": token_a_info,
|
|
228
|
+
"tokenB": token_b_info,
|
|
229
|
+
"token0": token0_addr,
|
|
230
|
+
"token1": token1_addr,
|
|
231
|
+
"pools": pool_results,
|
|
232
|
+
"recommendation": recommendation,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _print_comparison_table(result: dict[str, Any]) -> None:
|
|
237
|
+
chain = result["chain"]["key"]
|
|
238
|
+
ta = result["tokenA"]["symbol"]
|
|
239
|
+
tb = result["tokenB"]["symbol"]
|
|
240
|
+
print(f"\nPool Comparison: {ta}/{tb} on {chain}")
|
|
241
|
+
print()
|
|
242
|
+
header = (
|
|
243
|
+
f"{'Fee Tier':<10} "
|
|
244
|
+
f"{'Pool Address':<22} "
|
|
245
|
+
f"{'TVL':>14} "
|
|
246
|
+
f"{'Volume 24h':>14} "
|
|
247
|
+
f"{'APY':>10} "
|
|
248
|
+
f"{'Liquidity':>16}"
|
|
249
|
+
)
|
|
250
|
+
print(header)
|
|
251
|
+
print("-" * len(header) + "-" * 10)
|
|
252
|
+
|
|
253
|
+
for pool in result["pools"]:
|
|
254
|
+
fee_pct = pool["feePct"]
|
|
255
|
+
if not pool.get("exists"):
|
|
256
|
+
print(
|
|
257
|
+
f"{fee_pct:<10} "
|
|
258
|
+
f"{'not found':<22} "
|
|
259
|
+
f"{'-':>14} "
|
|
260
|
+
f"{'-':>14} "
|
|
261
|
+
f"{'-':>10} "
|
|
262
|
+
f"{'-':>16}"
|
|
263
|
+
)
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
addr = pool.get("poolAddress", "")
|
|
267
|
+
addr_short = addr[:8] + "..." + addr[-4:] if len(addr) > 14 else addr
|
|
268
|
+
tvl = _safe_float(pool.get("tvlUsd")) or _safe_float(pool.get("dexLiquidityUsd"))
|
|
269
|
+
vol = _safe_float(pool.get("volumeUsd1d")) or _safe_float(pool.get("dexVolume24h"))
|
|
270
|
+
apy = pool.get("apy")
|
|
271
|
+
liq = pool.get("liquidity", "-")
|
|
272
|
+
|
|
273
|
+
tvl_str = _fmt_usd(tvl) if tvl > 0 else "-"
|
|
274
|
+
vol_str = _fmt_usd(vol) if vol > 0 else "-"
|
|
275
|
+
apy_str = _fmt_pct(apy) if apy is not None else "-"
|
|
276
|
+
liq_str = str(liq) if liq != "-" else "-"
|
|
277
|
+
|
|
278
|
+
print(
|
|
279
|
+
f"{fee_pct:<10} "
|
|
280
|
+
f"{addr_short:<22} "
|
|
281
|
+
f"{tvl_str:>14} "
|
|
282
|
+
f"{vol_str:>14} "
|
|
283
|
+
f"{apy_str:>10} "
|
|
284
|
+
f"{liq_str:>16}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
print("-" * len(header) + "-" * 10)
|
|
288
|
+
|
|
289
|
+
rec = result.get("recommendation")
|
|
290
|
+
if rec:
|
|
291
|
+
print(f"\nRecommended: {rec['feePct']} ({rec['reason']})")
|
|
292
|
+
print(f" Pool: {rec['poolAddress']}")
|
|
293
|
+
else:
|
|
294
|
+
print("\nNo recommendation (no pools found)")
|
|
295
|
+
|
|
296
|
+
print()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def main() -> None:
|
|
300
|
+
parser = argparse.ArgumentParser(
|
|
301
|
+
description="Compare Uniswap v3 pools across fee tiers for a token pair"
|
|
302
|
+
)
|
|
303
|
+
parser.add_argument(
|
|
304
|
+
"--chain", required=True, help="Chain name, e.g. base / ethereum / arbitrum"
|
|
305
|
+
)
|
|
306
|
+
parser.add_argument(
|
|
307
|
+
"--token-a", required=True, help="Token symbol or address"
|
|
308
|
+
)
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
"--token-b", required=True, help="Token symbol or address"
|
|
311
|
+
)
|
|
312
|
+
parser.add_argument("--rpc-url", help="RPC URL (reads from env if not provided)")
|
|
313
|
+
parser.add_argument("--output", help="Output JSON file path")
|
|
314
|
+
|
|
315
|
+
args = parser.parse_args()
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
load_local_env()
|
|
319
|
+
result = compare_pools_for_pair(
|
|
320
|
+
args.chain, args.token_a, args.token_b, args.rpc_url
|
|
321
|
+
)
|
|
322
|
+
_print_comparison_table(result)
|
|
323
|
+
if args.output:
|
|
324
|
+
Path(args.output).write_text(
|
|
325
|
+
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
|
326
|
+
encoding="utf-8",
|
|
327
|
+
)
|
|
328
|
+
dump_json(result)
|
|
329
|
+
except Exception as exc:
|
|
330
|
+
print(
|
|
331
|
+
json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2),
|
|
332
|
+
file=sys.stderr,
|
|
333
|
+
)
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
main()
|
|
File without changes
|