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,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)}
|