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.
Files changed (80) hide show
  1. uni_exec_engine-0.2.0.dist-info/METADATA +576 -0
  2. uni_exec_engine-0.2.0.dist-info/RECORD +80 -0
  3. uni_exec_engine-0.2.0.dist-info/WHEEL +5 -0
  4. uni_exec_engine-0.2.0.dist-info/licenses/LICENSE +21 -0
  5. uni_exec_engine-0.2.0.dist-info/top_level.txt +1 -0
  6. uniswap_autopilot/__init__.py +3 -0
  7. uniswap_autopilot/analytics/__init__.py +0 -0
  8. uniswap_autopilot/analytics/il_calculator.py +525 -0
  9. uniswap_autopilot/analytics/portfolio.py +234 -0
  10. uniswap_autopilot/analytics/position.py +433 -0
  11. uniswap_autopilot/analytics/range_suggest.py +270 -0
  12. uniswap_autopilot/audit.py +194 -0
  13. uniswap_autopilot/common/__init__.py +0 -0
  14. uniswap_autopilot/common/approval_cleanup.py +255 -0
  15. uniswap_autopilot/common/check_balance.py +64 -0
  16. uniswap_autopilot/common/common.py +804 -0
  17. uniswap_autopilot/common/deep_link.py +132 -0
  18. uniswap_autopilot/common/gas.py +141 -0
  19. uniswap_autopilot/data/auto_trade_policy.example.json +29 -0
  20. uniswap_autopilot/data/chains.json +135 -0
  21. uniswap_autopilot/data/common-token-addresses.json +777 -0
  22. uniswap_autopilot/execute/__init__.py +0 -0
  23. uniswap_autopilot/execute/_internal/__init__.py +5 -0
  24. uniswap_autopilot/execute/_internal/constants.py +15 -0
  25. uniswap_autopilot/execute/_internal/preflight.py +150 -0
  26. uniswap_autopilot/execute/_internal/pure_signer.py +182 -0
  27. uniswap_autopilot/execute/_internal/rpc.py +462 -0
  28. uniswap_autopilot/execute/_internal/signer.py +298 -0
  29. uniswap_autopilot/execute/_internal/submit.py +73 -0
  30. uniswap_autopilot/execute/_internal/tx.py +370 -0
  31. uniswap_autopilot/execute/broadcast.py +380 -0
  32. uniswap_autopilot/execute/detect.py +52 -0
  33. uniswap_autopilot/execute/telegram_confirm.py +272 -0
  34. uniswap_autopilot/lp/compare_pools.py +338 -0
  35. uniswap_autopilot/lp/v2/__init__.py +0 -0
  36. uniswap_autopilot/lp/v2/approve.py +100 -0
  37. uniswap_autopilot/lp/v2/build_tx.py +226 -0
  38. uniswap_autopilot/lp/v2/flow.py +204 -0
  39. uniswap_autopilot/lp/v2/pair.py +135 -0
  40. uniswap_autopilot/lp/v2/positions.py +177 -0
  41. uniswap_autopilot/lp/v3/__init__.py +0 -0
  42. uniswap_autopilot/lp/v3/approve.py +109 -0
  43. uniswap_autopilot/lp/v3/auto_rebalance.py +282 -0
  44. uniswap_autopilot/lp/v3/build_tx.py +465 -0
  45. uniswap_autopilot/lp/v3/compound.py +258 -0
  46. uniswap_autopilot/lp/v3/flow.py +358 -0
  47. uniswap_autopilot/lp/v3/pool.py +175 -0
  48. uniswap_autopilot/lp/v3/position.py +112 -0
  49. uniswap_autopilot/lp/v3/tick.py +75 -0
  50. uniswap_autopilot/lp/v4/__init__.py +0 -0
  51. uniswap_autopilot/lp/v4/approve.py +105 -0
  52. uniswap_autopilot/lp/v4/build_tx.py +669 -0
  53. uniswap_autopilot/lp/v4/flow.py +368 -0
  54. uniswap_autopilot/lp/v4/pool.py +174 -0
  55. uniswap_autopilot/lp/v4/position.py +185 -0
  56. uniswap_autopilot/policy.py +371 -0
  57. uniswap_autopilot/price_feed.py +100 -0
  58. uniswap_autopilot/py.typed +0 -0
  59. uniswap_autopilot/search/__init__.py +0 -0
  60. uniswap_autopilot/search/risk.py +200 -0
  61. uniswap_autopilot/search/search.py +580 -0
  62. uniswap_autopilot/state_machine.py +315 -0
  63. uniswap_autopilot/swap/__init__.py +1 -0
  64. uniswap_autopilot/swap/deep_link.py +69 -0
  65. uniswap_autopilot/swap/extensions/__init__.py +2 -0
  66. uniswap_autopilot/swap/extensions/bridge.py +197 -0
  67. uniswap_autopilot/swap/extensions/limit_order.py +272 -0
  68. uniswap_autopilot/swap/extensions/slippage.py +123 -0
  69. uniswap_autopilot/swap/flow.py +693 -0
  70. uniswap_autopilot/swap/flow_core/__init__.py +2 -0
  71. uniswap_autopilot/swap/flow_core/artifacts.py +11 -0
  72. uniswap_autopilot/swap/flow_core/broadcast.py +50 -0
  73. uniswap_autopilot/swap/flow_core/diagnostics.py +216 -0
  74. uniswap_autopilot/swap/flow_core/paper.py +113 -0
  75. uniswap_autopilot/swap/flow_core/policy.py +142 -0
  76. uniswap_autopilot/swap/links/__init__.py +2 -0
  77. uniswap_autopilot/swap/links/deep_link.py +69 -0
  78. uniswap_autopilot/swap/trading_api/permit.py +41 -0
  79. uniswap_autopilot/swap/trading_api/quote.py +282 -0
  80. uniswap_autopilot/swap/trading_api/swap.py +248 -0
@@ -0,0 +1,804 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from decimal import Decimal, InvalidOperation
11
+ from typing import Any
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.parse import urlencode
14
+ from urllib.request import Request, urlopen
15
+
16
+ ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
17
+ AMOUNT_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)?$")
18
+ ASSET_ROOT = Path(__file__).resolve().parent.parent / "data"
19
+ COMMON_TOKEN_FILE = ASSET_ROOT / "common-token-addresses.json"
20
+ TOKEN_CACHE_FILE = ASSET_ROOT / "token-cache.json"
21
+ CHAINS_FILE = ASSET_ROOT / "chains.json"
22
+ SECURE_WALLET_ENV_CANDIDATES = (
23
+ "SECURE_WALLET_ADDRESS",
24
+ "TRADE_SIGNER_WALLET_ADDRESS",
25
+ )
26
+ HOT_WALLET_ENV_CANDIDATES = (
27
+ "HOT_WALLET_ADDRESS",
28
+ "UNISWAP_WALLET_ADDRESS",
29
+ "EXECUTOR_WALLET_ADDRESS",
30
+ "DEFAULT_WALLET_ADDRESS",
31
+ )
32
+ DEFAULT_WALLET_ENV_CANDIDATES = SECURE_WALLET_ENV_CANDIDATES + HOT_WALLET_ENV_CANDIDATES
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Token:
37
+ symbol: str
38
+ address: str
39
+ decimals: int
40
+ category: str | None = None
41
+ is_stable: bool = False
42
+ price_hint: str | None = None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Chain:
47
+ key: str
48
+ chain_id: int
49
+ native_symbol: str
50
+ wrapped_native_symbol: str
51
+ url_param: str
52
+ tokens: dict[str, Token]
53
+
54
+
55
+ def load_common_tokens() -> dict[str, dict[str, Token]]:
56
+ payload = json.loads(COMMON_TOKEN_FILE.read_text(encoding="utf-8"))
57
+ tokens_by_chain: dict[str, dict[str, Token]] = {}
58
+ for chain_name, tokens in payload.items():
59
+ if not isinstance(tokens, dict):
60
+ raise ValueError(f"invalid token catalog for chain '{chain_name}'")
61
+ chain_tokens: dict[str, Token] = {}
62
+ for symbol_key, token_data in tokens.items():
63
+ if not isinstance(token_data, dict):
64
+ raise ValueError(f"invalid token entry '{chain_name}.{symbol_key}'")
65
+ chain_tokens[symbol_key.upper()] = Token(
66
+ str(token_data["symbol"]),
67
+ str(token_data["address"]),
68
+ int(token_data["decimals"]),
69
+ str(token_data.get("category")) if token_data.get("category") is not None else None,
70
+ bool(token_data.get("isStable", False)),
71
+ str(token_data.get("priceHint")) if token_data.get("priceHint") is not None else None,
72
+ )
73
+ tokens_by_chain[chain_name] = chain_tokens
74
+ return tokens_by_chain
75
+
76
+
77
+ def _load_chains(tokens_by_chain: dict[str, dict[str, Token]]) -> dict[str, Chain]:
78
+ chains_cfg = json.loads(CHAINS_FILE.read_text(encoding="utf-8"))
79
+ chains: dict[str, Chain] = {}
80
+ for chain_key, cfg in chains_cfg.items():
81
+ if not isinstance(cfg, dict):
82
+ raise ValueError(f"invalid chain config for '{chain_key}'")
83
+ chains[chain_key] = Chain(
84
+ key=chain_key,
85
+ chain_id=int(cfg["chainId"]),
86
+ native_symbol=str(cfg["nativeSymbol"]),
87
+ wrapped_native_symbol=str(cfg["wrappedNativeSymbol"]),
88
+ url_param=str(cfg["urlParam"]),
89
+ tokens=tokens_by_chain.get(chain_key, {}),
90
+ )
91
+ return chains
92
+
93
+
94
+ COMMON_TOKENS = load_common_tokens()
95
+
96
+ CHAINS: dict[str, Chain] = _load_chains(COMMON_TOKENS)
97
+
98
+ LP_CONTRACTS: dict[str, dict[str, str]] = {
99
+ "ethereum": {
100
+ "nonfungiblePositionManager": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
101
+ "v3Factory": "0x1F98431c8aD98523631AE4a59f267346ea31F984",
102
+ },
103
+ "base": {
104
+ "nonfungiblePositionManager": "0x03a520b32c04bf3beef7beb72e919cf822ed34f1",
105
+ "v3Factory": "0x33128a8fC17869897dcE68Ed026d694621f6FDfD",
106
+ },
107
+ "arbitrum": {
108
+ "nonfungiblePositionManager": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
109
+ "v3Factory": "0x1F98431c8aD98523631AE4a59f267346ea31F984",
110
+ },
111
+ "optimism": {
112
+ "nonfungiblePositionManager": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
113
+ "v3Factory": "0x1F98431c8aD98523631AE4a59f267346ea31F984",
114
+ },
115
+ "polygon": {
116
+ "nonfungiblePositionManager": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
117
+ "v3Factory": "0x1F98431c8aD98523631AE4a59f267346ea31F984",
118
+ },
119
+ "celo": {
120
+ "nonfungiblePositionManager": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
121
+ "v3Factory": "0x1F98431c8aD98523631AE4a59f267346ea31F984",
122
+ },
123
+ "linea": {
124
+ "nonfungiblePositionManager": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
125
+ "v3Factory": "0x1F98431c8aD98523631AE4a59f267346ea31F984",
126
+ },
127
+ "world_chain": {
128
+ "nonfungiblePositionManager": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
129
+ "v3Factory": "0x1F98431c8aD98523631AE4a59f267346ea31F984",
130
+ },
131
+ "soneium": {
132
+ "nonfungiblePositionManager": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
133
+ "v3Factory": "0x1F98431c8aD98523631AE4a59f267346ea31F984",
134
+ },
135
+ }
136
+
137
+ V2_CONTRACTS: dict[str, dict[str, str]] = {
138
+ "ethereum": {
139
+ "factory": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
140
+ "router02": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
141
+ },
142
+ "optimism": {
143
+ "factory": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
144
+ "router02": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
145
+ },
146
+ "bsc": {
147
+ "factory": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
148
+ "router02": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
149
+ },
150
+ "polygon": {
151
+ "factory": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
152
+ "router02": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
153
+ },
154
+ "arbitrum": {
155
+ "factory": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
156
+ "router02": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
157
+ },
158
+ "avalanche": {
159
+ "factory": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
160
+ "router02": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
161
+ },
162
+ "world_chain": {
163
+ "factory": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
164
+ "router02": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
165
+ },
166
+ }
167
+
168
+ V3_FEE_TIERS = {100, 500, 3000, 10000}
169
+
170
+ PUBLIC_RPC_URLS: dict[str, str] = {
171
+ "ethereum": "https://eth.llamarpc.com",
172
+ "base": "https://mainnet.base.org",
173
+ "arbitrum": "https://arb1.arbitrum.io/rpc",
174
+ "optimism": "https://mainnet.optimism.io",
175
+ "polygon": "https://polygon-rpc.com",
176
+ "bsc": "https://bsc-dataseed.binance.org",
177
+ "avalanche": "https://api.avax.network/ext/bc/C/rpc",
178
+ "celo": "https://forno.celo.org",
179
+ "unichain": "https://mainnet.unichain.org",
180
+ "linea": "https://rpc.linea.build",
181
+ "blast": "https://rpc.blast.io",
182
+ "zora": "https://rpc.zora.energy",
183
+ "world_chain": "https://worldchain-mainnet.g.alchemy.com/public",
184
+ "soneium": "https://rpc.soneium.org",
185
+ "monad": "https://rpc.monad.xyz",
186
+ "x_layer": "https://rpc.xlayer.tech",
187
+ "zksync": "https://mainnet.era.zksync.io",
188
+ "tempo": "https://api.avax.network/ext/bc/C/rpc",
189
+ }
190
+
191
+
192
+ def get_position_manager_address(chain_name: str) -> str:
193
+ chain_key = chain_name.strip().lower()
194
+ if chain_key not in LP_CONTRACTS:
195
+ raise ValueError(f"LP not supported on chain '{chain_name}'")
196
+ return LP_CONTRACTS[chain_key]["nonfungiblePositionManager"]
197
+
198
+
199
+ def get_v3_factory_address(chain_name: str) -> str:
200
+ chain_key = chain_name.strip().lower()
201
+ if chain_key not in LP_CONTRACTS:
202
+ raise ValueError(f"LP not supported on chain '{chain_name}'")
203
+ return LP_CONTRACTS[chain_key]["v3Factory"]
204
+
205
+
206
+ def get_v2_factory_address(chain_name: str) -> str:
207
+ chain_key = chain_name.strip().lower()
208
+ if chain_key not in V2_CONTRACTS:
209
+ raise ValueError(f"V2 LP not supported on chain '{chain_name}'")
210
+ return V2_CONTRACTS[chain_key]["factory"]
211
+
212
+
213
+ def get_v2_router02_address(chain_name: str) -> str:
214
+ chain_key = chain_name.strip().lower()
215
+ if chain_key not in V2_CONTRACTS:
216
+ raise ValueError(f"V2 LP not supported on chain '{chain_name}'")
217
+ return V2_CONTRACTS[chain_key]["router02"]
218
+
219
+
220
+ PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3"
221
+
222
+ UNIVERSAL_ROUTER_ADDRESSES: dict[str, str] = {
223
+ "ethereum": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD",
224
+ "base": "0x198EF79F1F515F02dFE9e3115eD9fC07183f02fC",
225
+ "arbitrum": "0x4D9079Bb4165aeb4084c526a3C95e761A5DABb07",
226
+ "optimism": "0xc3e7cF3A5F74717452f52BEdE06991c50F960F7e",
227
+ "polygon": "0x1095692a6237d83c6a72f3f5efedb9a670c49223",
228
+ "bsc": "0x4D9079Bb4165aeb4084c526a3C95e761A5DABb07",
229
+ "avalanche": "0x4D9079Bb4165aeb4084c526a3C95e761A5DABb07",
230
+ "celo": "0x4D9079Bb4165aeb4084c526a3C95e761A5DABb07",
231
+ "world_chain": "0x4D9079Bb4165aeb4084c526a3C95e761A5DABb07",
232
+ "soneium": "0x4D9079Bb4165aeb4084c526a3C95e761A5DABb07",
233
+ "linea": "0x4D9079Bb4165aeb4084c526a3C95e761A5DABb07",
234
+ }
235
+
236
+
237
+ def get_permit2_address() -> str:
238
+ return PERMIT2_ADDRESS
239
+
240
+
241
+ def get_universal_router_address(chain_name: str) -> str | None:
242
+ chain_key = chain_name.strip().lower()
243
+ return UNIVERSAL_ROUTER_ADDRESSES.get(chain_key)
244
+
245
+
246
+ V4_CONTRACTS: dict[str, dict[str, str]] = {
247
+ "ethereum": {
248
+ "poolManager": "0x000000000004444c5dc75cB358380D2e3dE08A90",
249
+ "positionManager": "0xbD216513d74C8cf14Cf4747E6AaA6420ff64EE9e",
250
+ "stateView": "0x7ffe42c4a5deea5b0fec41c94c136cf115597227",
251
+ "quoter": "0x52f0e24d1c21c8a0cb1e5a5dd6198556bd9e1203",
252
+ },
253
+ "base": {
254
+ "poolManager": "0x498581ff718922c3f8e6a244956af099b2652b2b",
255
+ "positionManager": "0x7C5f5a4bBd8fd63184577525326123b519429Bdc",
256
+ "stateView": "0xA3c0c9b65bAd0b08107aa264b0F3Db444b867a71",
257
+ "quoter": "0x0d5e0f971ed27fbff6c2837bf31316121532048d",
258
+ },
259
+ "arbitrum": {
260
+ "poolManager": "0x360e68faccca8ca495c1b759fd9eee466db9fb32",
261
+ "positionManager": "0xd88f38f930b7952f2db2432cb002e7abbf3dd869",
262
+ "stateView": "0x76fd297e2d437cd7f76d50f01afe6160f86e9990",
263
+ "quoter": "0x3972c00f7ed4885e145823eb7c655375d275a1c5",
264
+ },
265
+ "optimism": {
266
+ "poolManager": "0x9a13f98cb987694c9f086b1f5eb990eea8264ec3",
267
+ "positionManager": "0x3c3ea4b57a46241e54610e5f022e5c45859a1017",
268
+ "stateView": "0xc18a3169788f4f75a170290584eca6395c75ecdb",
269
+ "quoter": "0x1f3131a13296fb91c90870043742c3cdbff1a8d7",
270
+ },
271
+ "polygon": {
272
+ "poolManager": "0x67366782805870060151383f4bbff9dab53e5cd6",
273
+ "positionManager": "0x1ec2ebf4f37e7363fdfe3551602425af0b3ceef9",
274
+ "stateView": "0x5ea1bd7974c8a611cbab0bdcafcb1d9cc9b3ba5a",
275
+ "quoter": "0xb3d5c3dfc3a7aebff71895a7191796bffc2c81b9",
276
+ },
277
+ "celo": {
278
+ "poolManager": "0x288dc841A52FCA2707c6947B3A777c5E56cd87BC",
279
+ "positionManager": "0xf7965f3981e4d5bc383bfbcb61501763e9068ca9",
280
+ "stateView": "0xbc21f8720babf4b20d195ee5c6e99c52b76f2bfb",
281
+ "quoter": "0x28566da1093609182dff2cb2a91cfd72e61d66cd",
282
+ },
283
+ "world_chain": {
284
+ "poolManager": "0xb1860d529182ac3bc1f51fa2abd56662b7d13f33",
285
+ "positionManager": "0xc585e0f504613b5fbf874f21af14c65260fb41fa",
286
+ "stateView": "0x51d394718bc09297262e368c1a481217fdeb71eb",
287
+ "quoter": "0x55d235b3ff2daf7c3ede0defc9521f1d6fe6c5c0",
288
+ },
289
+ "soneium": {
290
+ "poolManager": "0x360e68faccca8ca495c1b759fd9eee466db9fb32",
291
+ "positionManager": "0x1b35d13a2e2528f192637f14b05f0dc0e7deb566",
292
+ "stateView": "0x76fd297e2d437cd7f76d50f01afe6160f86e9990",
293
+ "quoter": "0x3972c00f7ed4885e145823eb7c655375d275a1c5",
294
+ },
295
+ "bsc": {
296
+ "poolManager": "0x28e2ea090877bf75740558f6bfb36a5ffee9e9df",
297
+ "positionManager": "0x7a4a5c919ae2541aed11041a1aeee68f1287f95b",
298
+ "stateView": "0xd13dd3d6e93f276fafc9db9e6bb47c1180aee0c4",
299
+ "quoter": "0x9f75dd27d6664c475b90e105573e550ff69437b0",
300
+ },
301
+ "avalanche": {
302
+ "poolManager": "0x06380c0e0912312b5150364b9dc4542ba0dbbc85",
303
+ "positionManager": "0xb74b1f14d2754acfcbbe1a221023a5cf50ab8acd",
304
+ "stateView": "0xc3c9e198c735a4b97e3e683f391ccbdd60b69286",
305
+ "quoter": "0xbe40675bb704506a3c2ccfb762dcfd1e979845c2",
306
+ },
307
+ "blast": {
308
+ "poolManager": "0x1631559198a9e474033433b2958dabc135ab6446",
309
+ "positionManager": "0x4ad2f4cca2682cbb5b950d660dd458a1d3f1baad",
310
+ "stateView": "0x12a88ae16f46dce4e8b15368008ab3380885df30",
311
+ "quoter": "0x6f71cdcb0d119ff72c6eb501abceb576fbf62bcf",
312
+ },
313
+ "zora": {
314
+ "poolManager": "0x0575338e4c17006ae181b47900a84404247ca30f",
315
+ "positionManager": "0xf66c7b99e2040f0d9b326b3b7c152e9663543d63",
316
+ "stateView": "0x385785af07d63b50d0a0ea57c4ff89d06adf7328",
317
+ "quoter": "0x5edaccc0660e0a2c44b06e07ce8b915e625dc2c6",
318
+ },
319
+ "unichain": {
320
+ "poolManager": "0x1f98400000000000000000000000000000000004",
321
+ "positionManager": "0x4529a01c7a0410167c5740c487a8de60232617bf",
322
+ "stateView": "0x86e8631a016f9068c3f085faf484ee3f5fdee8f2",
323
+ "quoter": "0x333e3c607b141b18ff6de9f258db6e77fe7491e0",
324
+ },
325
+ }
326
+
327
+
328
+ def _get_v4_contract(chain_name: str, contract_key: str) -> str:
329
+ chain_key = chain_name.strip().lower()
330
+ if chain_key not in V4_CONTRACTS:
331
+ raise ValueError(f"V4 not supported on chain '{chain_name}'")
332
+ return V4_CONTRACTS[chain_key][contract_key]
333
+
334
+
335
+ def get_v4_pool_manager_address(chain_name: str) -> str:
336
+ return _get_v4_contract(chain_name, "poolManager")
337
+
338
+
339
+ def get_v4_position_manager_address(chain_name: str) -> str:
340
+ return _get_v4_contract(chain_name, "positionManager")
341
+
342
+
343
+ def get_v4_state_view_address(chain_name: str) -> str:
344
+ return _get_v4_contract(chain_name, "stateView")
345
+
346
+
347
+ def get_v4_quoter_address(chain_name: str) -> str:
348
+ return _get_v4_contract(chain_name, "quoter")
349
+
350
+
351
+ def validate_fee_tier(fee: int) -> int:
352
+ if fee not in V3_FEE_TIERS:
353
+ raise ValueError(f"invalid fee tier {fee}; valid: {sorted(V3_FEE_TIERS)}")
354
+ return fee
355
+
356
+
357
+ def sort_token_addresses(token_a: str, token_b: str) -> tuple[str, str]:
358
+ a = token_a.lower()
359
+ b = token_b.lower()
360
+ if a == b:
361
+ raise ValueError("token0 and token1 must be different")
362
+ return (token_a, token_b) if a < b else (token_b, token_a)
363
+
364
+
365
+ def normalize_chain(chain_name: str) -> Chain:
366
+ key = chain_name.strip().lower()
367
+ if key not in CHAINS:
368
+ supported = ", ".join(sorted(CHAINS))
369
+ raise ValueError(f"unsupported chain '{chain_name}', supported: {supported}")
370
+ return CHAINS[key]
371
+
372
+
373
+ # ─── Token Cache ────────────────────────────────────────────────
374
+
375
+ def _load_token_cache() -> dict[str, dict[str, dict[str, Any]]]:
376
+ """Load token-cache.json; return {} on missing / corrupt."""
377
+ if not TOKEN_CACHE_FILE.exists():
378
+ return {}
379
+ try:
380
+ data = json.loads(TOKEN_CACHE_FILE.read_text(encoding="utf-8"))
381
+ if isinstance(data, dict):
382
+ return data
383
+ except (json.JSONDecodeError, OSError):
384
+ pass
385
+ return {}
386
+
387
+
388
+ def _save_token_cache(cache: dict[str, dict[str, dict[str, Any]]]) -> None:
389
+ """Persist token cache with sorted keys for diff-friendliness."""
390
+ TOKEN_CACHE_FILE.write_text(
391
+ json.dumps(cache, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
392
+ encoding="utf-8",
393
+ )
394
+
395
+
396
+ def _cache_get(chain_key: str, symbol_or_address: str) -> dict[str, Any] | None:
397
+ """Look up cache by (chain, upper(symbol)) or (chain, lower(address))."""
398
+ cache = _load_token_cache()
399
+ chain_bucket = cache.get(chain_key, {})
400
+ key = symbol_or_address.upper()
401
+ if key in chain_bucket:
402
+ return dict(chain_bucket[key]) # shallow copy
403
+ # try address lookup
404
+ lower = symbol_or_address.lower()
405
+ for entry in chain_bucket.values():
406
+ if entry.get("address", "").lower() == lower:
407
+ return dict(entry)
408
+ return None
409
+
410
+
411
+ def _cache_put(chain_key: str, symbol: str, entry: dict[str, Any]) -> None:
412
+ """Write one entry into the token cache. Symbol is upper-cased."""
413
+ cache = _load_token_cache()
414
+ chain_bucket = cache.setdefault(chain_key, {})
415
+ entry_copy = dict(entry)
416
+ entry_copy["cachedAt"] = __import__("time").time()
417
+ chain_bucket[symbol.upper()] = entry_copy
418
+ _save_token_cache(cache)
419
+
420
+
421
+ def cache_token_from_search(
422
+ chain_key: str, symbol: str, address: str, decimals: int,
423
+ category: str | None = None, is_stable: bool = False,
424
+ ) -> None:
425
+ """Public API: write a token entry (e.g. from search results) into cache."""
426
+ _cache_put(chain_key, symbol, {
427
+ "kind": "erc20",
428
+ "symbol": symbol,
429
+ "address": address,
430
+ "decimals": decimals,
431
+ "category": category,
432
+ "isStable": is_stable,
433
+ "priceHint": None,
434
+ })
435
+
436
+
437
+ # ─── End Token Cache ────────────────────────────────────────────
438
+
439
+
440
+ def _auto_search_token(chain_key: str, symbol: str) -> dict[str, Any] | None:
441
+ """Try to find a token via DexScreener and cache the result."""
442
+ try:
443
+ from uniswap_autopilot.search.search import search_tokens # type: ignore
444
+ except ImportError:
445
+ return None
446
+ try:
447
+ results = search_tokens(symbol, chain=chain_key, limit=5)
448
+ except Exception:
449
+ return None
450
+ if not results:
451
+ return None
452
+ # Pick the best match: prefer exact symbol match, then highest liquidity
453
+ best = None
454
+ for r in results:
455
+ if r.get("symbol", "").upper() == symbol.upper():
456
+ best = r
457
+ break
458
+ if best is None:
459
+ best = results[0]
460
+ addr = best.get("address", "")
461
+ if not addr:
462
+ return None
463
+ entry = {
464
+ "kind": "erc20",
465
+ "symbol": best.get("symbol", symbol),
466
+ "address": addr,
467
+ "decimals": best.get("decimals") or 18,
468
+ "category": None,
469
+ "isStable": False,
470
+ "priceHint": None,
471
+ }
472
+ _cache_put(chain_key, symbol, entry)
473
+ return entry
474
+
475
+
476
+ def read_erc20_metadata(token_address: str, rpc_url: str) -> dict[str, Any]:
477
+ """Read ERC-20 decimals and symbol from chain via JSON-RPC."""
478
+ from uniswap_autopilot.execute._internal.rpc import read_erc20_decimals, read_erc20_symbol
479
+ decimals = 18
480
+ try:
481
+ decimals = read_erc20_decimals(token_address, rpc_url)
482
+ except Exception:
483
+ pass
484
+
485
+ symbol = token_address
486
+ try:
487
+ sym = read_erc20_symbol(token_address, rpc_url)
488
+ if sym and not sym.startswith("0x"):
489
+ symbol = sym
490
+ except Exception:
491
+ pass
492
+
493
+ return {"symbol": symbol, "decimals": decimals}
494
+
495
+
496
+ def is_native(chain: Chain, token: str) -> bool:
497
+ normalized = token.strip().upper()
498
+ if normalized == "NATIVE":
499
+ return True
500
+ return normalized == chain.native_symbol.upper()
501
+
502
+
503
+ def resolve_token(chain: Chain, token: str, rpc_url: str | None = None) -> dict[str, Any]:
504
+ normalized = token.strip()
505
+ upper = normalized.upper()
506
+ if is_native(chain, normalized):
507
+ return {
508
+ "kind": "native",
509
+ "symbol": chain.native_symbol,
510
+ "address": "NATIVE",
511
+ "decimals": 18,
512
+ }
513
+ if ADDRESS_RE.fullmatch(normalized):
514
+ # Check cache first for address-based lookups
515
+ cached = _cache_get(chain.key, normalized)
516
+ if cached and cached.get("address", "").lower() == normalized.lower():
517
+ return cached
518
+ # Cache miss: read from chain and cache the result
519
+ meta = {}
520
+ effective_rpc = rpc_url or PUBLIC_RPC_URLS.get(chain.key)
521
+ if effective_rpc:
522
+ meta = read_erc20_metadata(normalized, effective_rpc)
523
+ result = {
524
+ "kind": "erc20",
525
+ "symbol": meta.get("symbol", normalized),
526
+ "address": normalized,
527
+ "decimals": meta.get("decimals", 18),
528
+ "category": None,
529
+ "isStable": False,
530
+ "priceHint": None,
531
+ }
532
+ # Cache by symbol if we got a readable symbol
533
+ sym = result["symbol"]
534
+ if sym and not sym.startswith("0x"):
535
+ _cache_put(chain.key, sym, result)
536
+ return result
537
+ if upper in chain.tokens:
538
+ token_cfg = chain.tokens[upper]
539
+ return {
540
+ "kind": "erc20",
541
+ "symbol": token_cfg.symbol,
542
+ "address": token_cfg.address,
543
+ "decimals": token_cfg.decimals,
544
+ "category": token_cfg.category,
545
+ "isStable": token_cfg.is_stable,
546
+ "priceHint": token_cfg.price_hint,
547
+ }
548
+ # Symbol not in built-in list — check cache
549
+ cached = _cache_get(chain.key, upper)
550
+ if cached:
551
+ return cached
552
+ # Cache miss: try auto-search via DexScreener / GeckoTerminal
553
+ resolved = _auto_search_token(chain.key, normalized)
554
+ if resolved:
555
+ return resolved
556
+ raise ValueError(
557
+ f"unknown token '{token}' on {chain.key}; "
558
+ f"use a known symbol or a token address, or search first"
559
+ )
560
+
561
+
562
+ def resolve_quote_token(chain: Chain, token: dict[str, Any]) -> dict[str, Any]:
563
+ if token["address"] != "NATIVE":
564
+ return token
565
+ wrapped = chain.tokens.get(chain.wrapped_native_symbol.upper())
566
+ if not wrapped:
567
+ raise ValueError(f"wrapped native token is not configured for chain '{chain.key}'")
568
+ return {
569
+ "kind": "erc20",
570
+ "symbol": wrapped.symbol,
571
+ "address": wrapped.address,
572
+ "decimals": wrapped.decimals,
573
+ "category": wrapped.category,
574
+ "isStable": wrapped.is_stable,
575
+ "priceHint": wrapped.price_hint,
576
+ "wrappedFromNative": True,
577
+ }
578
+
579
+
580
+ def parse_amount(amount: str) -> Decimal:
581
+ if not AMOUNT_RE.fullmatch(amount.strip()):
582
+ raise ValueError(f"invalid amount '{amount}'")
583
+ try:
584
+ parsed = Decimal(amount)
585
+ except InvalidOperation as exc:
586
+ raise ValueError(f"invalid amount '{amount}'") from exc
587
+ if parsed <= 0:
588
+ raise ValueError("amount must be greater than 0")
589
+ return parsed
590
+
591
+
592
+ def decimal_to_base_units(amount: Decimal, decimals: int) -> str:
593
+ scaled = amount * (Decimal(10) ** decimals)
594
+ if scaled != scaled.to_integral_value():
595
+ raise ValueError(
596
+ f"amount {amount} has too many decimal places for token decimals={decimals}"
597
+ )
598
+ return str(int(scaled))
599
+
600
+
601
+ def validate_address(value: str, field_name: str) -> str:
602
+ if not ADDRESS_RE.fullmatch(value):
603
+ raise ValueError(f"{field_name} must be a valid EVM address")
604
+ return value
605
+
606
+
607
+ def resolve_wallet_address(
608
+ value: str | None,
609
+ field_name: str = "wallet",
610
+ preference: str = "any",
611
+ ) -> str | None:
612
+ if value:
613
+ return validate_address(value, field_name)
614
+ if preference == "secure":
615
+ candidates = SECURE_WALLET_ENV_CANDIDATES
616
+ elif preference == "hot":
617
+ candidates = HOT_WALLET_ENV_CANDIDATES
618
+ else:
619
+ candidates = DEFAULT_WALLET_ENV_CANDIDATES
620
+ for env_name in candidates:
621
+ env_value = os.environ.get(env_name, "").strip()
622
+ if env_value:
623
+ return validate_address(env_value, env_name)
624
+ return None
625
+
626
+
627
+ NATIVE_LINK_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"
628
+
629
+
630
+ def _link_address(token: dict[str, Any]) -> str:
631
+ if token.get("address") == "NATIVE":
632
+ return NATIVE_LINK_ADDRESS
633
+ return token["address"]
634
+
635
+
636
+ def build_swap_link(
637
+ chain: Chain,
638
+ token_in: dict[str, Any],
639
+ token_out: dict[str, Any],
640
+ amount: str,
641
+ field: str,
642
+ ) -> str:
643
+ params = {
644
+ "chain": chain.url_param,
645
+ "inputCurrency": _link_address(token_in),
646
+ "outputCurrency": _link_address(token_out),
647
+ "value": amount,
648
+ "field": field,
649
+ }
650
+ return f"https://app.uniswap.org/swap?{urlencode(params)}"
651
+
652
+
653
+ def dump_json(payload: dict[str, Any]) -> None:
654
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
655
+
656
+
657
+ def add_common_arguments(parser: argparse.ArgumentParser) -> None:
658
+ parser.add_argument("--chain", required=True, help="链名,例如 base / ethereum")
659
+ parser.add_argument("--token-in", required=True, help="输入 token symbol、地址或 NATIVE")
660
+ parser.add_argument("--token-out", required=True, help="输出 token symbol、地址或 NATIVE")
661
+ parser.add_argument("--amount", required=True, help="人类可读数量,例如 1 或 250.5")
662
+ parser.add_argument(
663
+ "--token-in-decimals",
664
+ type=int,
665
+ help="当 --token-in 直接传地址且不是内置 symbol 时,显式指定 decimals",
666
+ )
667
+ parser.add_argument(
668
+ "--token-out-decimals",
669
+ type=int,
670
+ help="当 --token-out 直接传地址且不是内置 symbol 时,显式指定 decimals",
671
+ )
672
+
673
+
674
+ def override_decimals(token: dict[str, Any], decimals: int | None) -> dict[str, Any]:
675
+ if decimals is None:
676
+ return token
677
+ if decimals < 0 or decimals > 255:
678
+ raise ValueError("token decimals must be between 0 and 255")
679
+ updated = dict(token)
680
+ updated["decimals"] = decimals
681
+ return updated
682
+
683
+
684
+ def native_currency_address() -> str:
685
+ return "0x0000000000000000000000000000000000000000"
686
+
687
+
688
+ def resolve_api_token(chain: Chain, token: dict[str, Any]) -> dict[str, Any]:
689
+ if token["address"] != "NATIVE":
690
+ return token
691
+ return {
692
+ "kind": "native",
693
+ "symbol": chain.native_symbol,
694
+ "address": native_currency_address(),
695
+ "decimals": 18,
696
+ "nativeInput": True,
697
+ }
698
+
699
+
700
+ API_BASE = "https://trade-api.gateway.uniswap.org/v1"
701
+ BROWSER_LIKE_USER_AGENT = (
702
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
703
+ "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
704
+ )
705
+
706
+
707
+ def require_api_key() -> str:
708
+ api_key = os.environ.get("UNISWAP_API_KEY")
709
+ if not api_key:
710
+ raise RuntimeError("UNISWAP_API_KEY is not set")
711
+ return api_key
712
+
713
+
714
+ def post_json(endpoint: str, payload: dict[str, Any], api_key: str) -> dict[str, Any]:
715
+ uses_native = False
716
+ for field in ("tokenIn", "tokenOut"):
717
+ if payload.get(field) == native_currency_address():
718
+ uses_native = True
719
+ break
720
+ if not uses_native:
721
+ quote = payload.get("quote") or {}
722
+ for field in ("input", "output"):
723
+ section = quote.get(field) or {}
724
+ if section.get("token") == native_currency_address():
725
+ uses_native = True
726
+ break
727
+ url = f"{API_BASE}/{endpoint.lstrip('/')}"
728
+ data = json.dumps(payload).encode("utf-8")
729
+ request = Request(
730
+ url,
731
+ data=data,
732
+ method="POST",
733
+ headers={
734
+ "Content-Type": "application/json",
735
+ "x-api-key": api_key,
736
+ "Accept": "application/json",
737
+ "x-universal-router-version": "2.0",
738
+ "User-Agent": BROWSER_LIKE_USER_AGENT,
739
+ "Origin": "https://app.uniswap.org",
740
+ "Referer": "https://app.uniswap.org/",
741
+ **({"x-erc20eth-enabled": "true"} if uses_native else {}),
742
+ },
743
+ )
744
+ try:
745
+ with urlopen(request, timeout=20) as response:
746
+ return json.loads(response.read().decode("utf-8"))
747
+ except HTTPError as exc:
748
+ body = exc.read().decode("utf-8", errors="replace")
749
+ raise RuntimeError(f"Trading API HTTP {exc.code}: {body}") from exc
750
+ except URLError as exc:
751
+ raise RuntimeError(f"Trading API request failed: {exc.reason}") from exc
752
+
753
+
754
+ def skill_root() -> Path:
755
+ """Return the project root directory."""
756
+ return Path(__file__).resolve().parent.parent.parent.parent
757
+
758
+
759
+ def load_local_env() -> dict[str, str]:
760
+ loaded: dict[str, str] = {}
761
+ candidates = [skill_root() / ".env.local", skill_root() / ".env"]
762
+ for path in candidates:
763
+ if not path.exists():
764
+ continue
765
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
766
+ line = raw_line.strip()
767
+ if not line or line.startswith("#") or "=" not in line:
768
+ continue
769
+ key, value = line.split("=", 1)
770
+ key = key.strip()
771
+ value = value.strip().strip('"').strip("'")
772
+ if not key:
773
+ continue
774
+ os.environ.setdefault(key, value)
775
+ loaded[key] = value
776
+ return loaded
777
+
778
+
779
+ def check_balance(
780
+ chain: Chain,
781
+ token: dict[str, Any],
782
+ amount_base_units: str,
783
+ owner: str,
784
+ rpc_url: str | None = None,
785
+ ) -> dict[str, Any]:
786
+ if not rpc_url:
787
+ return {"checked": False, "ok": None, "reason": "RPC URL not configured"}
788
+ try:
789
+ from uniswap_autopilot.execute._internal.rpc import query_erc20_balance, query_native_balance
790
+ if token.get("address") == "NATIVE" or token.get("kind") == "native":
791
+ raw = query_native_balance(owner, rpc_url)
792
+ else:
793
+ raw = query_erc20_balance(owner, token["address"], rpc_url)
794
+ required = int(amount_base_units)
795
+ ok = raw >= required
796
+ return {
797
+ "checked": True,
798
+ "ok": ok,
799
+ "balance": str(raw),
800
+ "required": str(required),
801
+ "shortfall": str(required - raw) if not ok else "0",
802
+ }
803
+ except Exception as exc:
804
+ return {"checked": False, "ok": None, "reason": str(exc)}