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,462 @@
|
|
|
1
|
+
"""EVM RPC utilities — pure JSON-RPC over urllib, no external CLI dependencies.
|
|
2
|
+
|
|
3
|
+
All chain interactions (balance, allowance, gas, receipts) use standard
|
|
4
|
+
JSON-RPC calls. Falls back to ``cast`` (Foundry) only when explicitly
|
|
5
|
+
requested via the ``CAST_EXECUTABLE`` environment variable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
|
|
17
|
+
from uniswap_autopilot.execute._internal.constants import CHAIN_BY_ID, GLOBAL_RPC_ENV_CANDIDATES
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── JSON-RPC helpers ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def _json_rpc(method: str, params: list[Any], rpc_url: str, timeout: int = 30) -> Any:
|
|
23
|
+
"""Send a JSON-RPC request and return the ``result`` field."""
|
|
24
|
+
payload = json.dumps({
|
|
25
|
+
"jsonrpc": "2.0",
|
|
26
|
+
"id": 1,
|
|
27
|
+
"method": method,
|
|
28
|
+
"params": params,
|
|
29
|
+
}).encode("utf-8")
|
|
30
|
+
req = Request(rpc_url, data=payload, headers={"Content-Type": "application/json"})
|
|
31
|
+
with urlopen(req, timeout=timeout) as resp:
|
|
32
|
+
body = json.loads(resp.read())
|
|
33
|
+
if body.get("error"):
|
|
34
|
+
raise RuntimeError(f"RPC error: {body['error']}")
|
|
35
|
+
return body.get("result")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _decode_int(hex_or_int: Any) -> int:
|
|
39
|
+
"""Decode a hex string or int from RPC response."""
|
|
40
|
+
if isinstance(hex_or_int, int):
|
|
41
|
+
return hex_or_int
|
|
42
|
+
if isinstance(hex_or_int, str):
|
|
43
|
+
return int(hex_or_int, 0)
|
|
44
|
+
raise ValueError(f"Cannot decode integer from {type(hex_or_int)}: {hex_or_int}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── ERC-20 ABI selectors ───────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
_ERC20_BALANCE_OF = "0x70a08231" # balanceOf(address)
|
|
50
|
+
_ERC20_ALLOWANCE = "0xdd62ed3e" # allowance(address,address)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _encode_address(addr: str) -> str:
|
|
54
|
+
"""Left-pad address to 32 bytes."""
|
|
55
|
+
return addr.lower().replace("0x", "").rjust(64, "0")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _encode_uint256(val: int) -> str:
|
|
59
|
+
"""Encode uint256 to 32-byte hex."""
|
|
60
|
+
return hex(val)[2:].rjust(64, "0")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── cast CLI fallback ──────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def _has_cast() -> bool:
|
|
66
|
+
"""Check if ``cast`` (Foundry) is available."""
|
|
67
|
+
return bool(os.environ.get("CAST_EXECUTABLE", "")) or _which("cast")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _which(name: str) -> bool:
|
|
71
|
+
"""Check if a command exists on PATH."""
|
|
72
|
+
try:
|
|
73
|
+
subprocess.run(["which", name], capture_output=True, check=True)
|
|
74
|
+
return True
|
|
75
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_cast_text(command: list[str]) -> str:
|
|
80
|
+
"""Run a ``cast`` command and return stdout. Requires Foundry."""
|
|
81
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
82
|
+
stdout = completed.stdout.strip()
|
|
83
|
+
stderr = completed.stderr.strip()
|
|
84
|
+
if completed.returncode != 0:
|
|
85
|
+
raise RuntimeError(stderr or stdout or f"{shlex.join(command)} failed with exit code {completed.returncode}")
|
|
86
|
+
if not stdout:
|
|
87
|
+
raise RuntimeError(f"{shlex.join(command)} returned empty stdout")
|
|
88
|
+
return stdout
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_cast_int_output(stdout: str, field_name: str) -> int:
|
|
92
|
+
value = stdout.strip().splitlines()[-1].strip()
|
|
93
|
+
if value.startswith('"') and value.endswith('"'):
|
|
94
|
+
try:
|
|
95
|
+
value = json.loads(value)
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
pass
|
|
98
|
+
if isinstance(value, str) and " [" in value:
|
|
99
|
+
value = value.split(" [", 1)[0].strip()
|
|
100
|
+
try:
|
|
101
|
+
return int(value, 0)
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
raise RuntimeError(f"{field_name} returned non-integer output: {stdout}") from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── Public API (cast-free by default) ─────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def query_native_balance(address: str, rpc_url: str) -> int:
|
|
109
|
+
"""Query native token balance (wei) via ``eth_getBalance``."""
|
|
110
|
+
result = _json_rpc("eth_getBalance", [address, "latest"], rpc_url)
|
|
111
|
+
return _decode_int(result)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def query_erc20_balance(owner: str, token: str, rpc_url: str) -> int:
|
|
115
|
+
"""Query ERC-20 balance via ``eth_call``."""
|
|
116
|
+
data = _ERC20_BALANCE_OF + _encode_address(owner)
|
|
117
|
+
result = _json_rpc("eth_call", [{"to": token, "data": "0x" + data}, "latest"], rpc_url)
|
|
118
|
+
return _decode_int(result)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def query_erc20_allowance(token: str, owner: str, spender: str, rpc_url: str) -> int:
|
|
122
|
+
"""Query ERC-20 allowance via ``eth_call``."""
|
|
123
|
+
data = _ERC20_ALLOWANCE + _encode_address(owner) + _encode_address(spender)
|
|
124
|
+
result = _json_rpc("eth_call", [{"to": token, "data": "0x" + data}, "latest"], rpc_url)
|
|
125
|
+
return _decode_int(result)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def estimate_transaction_gas(tx: dict[str, Any], rpc_url: str) -> int:
|
|
129
|
+
"""Estimate gas for a transaction via ``eth_estimateGas``."""
|
|
130
|
+
params: dict[str, str] = {
|
|
131
|
+
"to": tx["to"],
|
|
132
|
+
"data": tx["data"],
|
|
133
|
+
"value": hex(int(tx["value"])),
|
|
134
|
+
}
|
|
135
|
+
if tx.get("from"):
|
|
136
|
+
params["from"] = tx["from"]
|
|
137
|
+
result = _json_rpc("eth_estimateGas", [params], rpc_url)
|
|
138
|
+
return _decode_int(result)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def query_gas_price(rpc_url: str) -> int:
|
|
142
|
+
"""Query current gas price via ``eth_gasPrice``."""
|
|
143
|
+
result = _json_rpc("eth_gasPrice", [], rpc_url)
|
|
144
|
+
return _decode_int(result)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def execute_cast_receipt(
|
|
148
|
+
tx_hash: str,
|
|
149
|
+
rpc_url: str,
|
|
150
|
+
confirmations: int = 1,
|
|
151
|
+
*,
|
|
152
|
+
poll_interval: float = 2.0,
|
|
153
|
+
max_wait_seconds: float = 300.0,
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
"""Wait for a transaction receipt and ``confirmations`` additional blocks.
|
|
156
|
+
|
|
157
|
+
Polls ``eth_getTransactionReceipt`` until the tx is mined, then waits
|
|
158
|
+
until ``current_block >= receipt.blockNumber + (confirmations - 1)``.
|
|
159
|
+
Previously this function fetched the receipt once and ignored the
|
|
160
|
+
``confirmations`` argument entirely, which silently lied to callers
|
|
161
|
+
that thought they were waiting for finality.
|
|
162
|
+
|
|
163
|
+
Raises ``TimeoutError`` if the receipt is not seen within
|
|
164
|
+
``max_wait_seconds``.
|
|
165
|
+
"""
|
|
166
|
+
import time as _time
|
|
167
|
+
|
|
168
|
+
if confirmations < 1:
|
|
169
|
+
confirmations = 1
|
|
170
|
+
|
|
171
|
+
deadline = _time.monotonic() + max_wait_seconds
|
|
172
|
+
receipt: dict[str, Any] | None = None
|
|
173
|
+
while _time.monotonic() < deadline:
|
|
174
|
+
result = _json_rpc("eth_getTransactionReceipt", [tx_hash], rpc_url)
|
|
175
|
+
if result is not None:
|
|
176
|
+
receipt = result
|
|
177
|
+
break
|
|
178
|
+
_time.sleep(poll_interval)
|
|
179
|
+
|
|
180
|
+
if receipt is None:
|
|
181
|
+
raise TimeoutError(f"Receipt for tx {tx_hash} not seen within {max_wait_seconds}s")
|
|
182
|
+
|
|
183
|
+
if confirmations == 1:
|
|
184
|
+
return receipt
|
|
185
|
+
|
|
186
|
+
receipt_block_raw = receipt.get("blockNumber")
|
|
187
|
+
if receipt_block_raw is None:
|
|
188
|
+
return receipt
|
|
189
|
+
receipt_block = _decode_int(receipt_block_raw)
|
|
190
|
+
|
|
191
|
+
while _time.monotonic() < deadline:
|
|
192
|
+
head = _decode_int(_json_rpc("eth_blockNumber", [], rpc_url))
|
|
193
|
+
if head - receipt_block + 1 >= confirmations:
|
|
194
|
+
return receipt
|
|
195
|
+
_time.sleep(poll_interval)
|
|
196
|
+
|
|
197
|
+
raise TimeoutError(
|
|
198
|
+
f"Tx {tx_hash} mined at block {receipt_block} but only saw "
|
|
199
|
+
f"{head - receipt_block + 1} confirmation(s) within {max_wait_seconds}s "
|
|
200
|
+
f"(requested {confirmations})"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def receipt_succeeded(receipt: dict[str, Any]) -> bool:
|
|
205
|
+
"""Check if a receipt indicates success."""
|
|
206
|
+
status = receipt.get("status")
|
|
207
|
+
if isinstance(status, str):
|
|
208
|
+
return status.lower() in {"0x1", "1"}
|
|
209
|
+
if isinstance(status, int):
|
|
210
|
+
return status == 1
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ── RPC URL resolution ────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def rpc_env_candidates(chain_id: int) -> list[str]:
|
|
217
|
+
chain = CHAIN_BY_ID.get(chain_id)
|
|
218
|
+
candidates: list[str] = []
|
|
219
|
+
if chain:
|
|
220
|
+
key = chain.key.upper()
|
|
221
|
+
candidates.extend([
|
|
222
|
+
f"{key}_RPC_URL",
|
|
223
|
+
f"RPC_URL_{key}",
|
|
224
|
+
f"{key}_MAINNET_RPC_URL",
|
|
225
|
+
])
|
|
226
|
+
candidates.extend(GLOBAL_RPC_ENV_CANDIDATES)
|
|
227
|
+
deduped: list[str] = []
|
|
228
|
+
for candidate in candidates:
|
|
229
|
+
if candidate not in deduped:
|
|
230
|
+
deduped.append(candidate)
|
|
231
|
+
return deduped
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def resolve_rpc_url(explicit_rpc_url: str | None, chain_id: int) -> tuple[str | None, list[str]]:
|
|
235
|
+
"""Resolve RPC URL from explicit value or environment variables."""
|
|
236
|
+
if explicit_rpc_url:
|
|
237
|
+
return explicit_rpc_url, []
|
|
238
|
+
candidates = rpc_env_candidates(chain_id)
|
|
239
|
+
for env_name in candidates:
|
|
240
|
+
value = os.environ.get(env_name)
|
|
241
|
+
if value:
|
|
242
|
+
return value, candidates
|
|
243
|
+
return None, candidates
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── High-level eth_call wrapper ──────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def eth_call(to: str, data: str, rpc_url: str, block: str = "latest") -> str:
|
|
249
|
+
"""Make an ``eth_call`` and return the raw hex result string.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
to : str
|
|
254
|
+
Contract address (0x-prefixed).
|
|
255
|
+
data : str
|
|
256
|
+
ABI-encoded call data (4-byte selector + encoded args, 0x-prefixed).
|
|
257
|
+
rpc_url : str
|
|
258
|
+
JSON-RPC endpoint.
|
|
259
|
+
block : str
|
|
260
|
+
Block tag (default ``"latest"``).
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
str
|
|
265
|
+
Raw hex return value from the contract (``0x`` prefixed).
|
|
266
|
+
"""
|
|
267
|
+
result = _json_rpc("eth_call", [{"to": to, "data": data}, block], rpc_url)
|
|
268
|
+
if result is None:
|
|
269
|
+
raise RuntimeError(f"eth_call to {to} returned null (contract may have reverted)")
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── Minimal ABI encoding (pure Python, no deps) ──────────────────────────
|
|
274
|
+
|
|
275
|
+
# keccak256 would be ideal but requires hashlib which supports it on Python 3.6+
|
|
276
|
+
# For the selectors we need, we hardcode them (they're standard ERC function signatures).
|
|
277
|
+
|
|
278
|
+
# Common function selectors (keccak256 first 4 bytes)
|
|
279
|
+
_SELECTORS: dict[str, str] = {
|
|
280
|
+
"decimals()": "0x313ce567",
|
|
281
|
+
"symbol()": "0x95d89b41",
|
|
282
|
+
"name()": "0x06fdde03",
|
|
283
|
+
"totalSupply()": "0x18160ddd",
|
|
284
|
+
"balanceOf(address)": "0x70a08231",
|
|
285
|
+
"allowance(address,address)": "0xdd62ed3e",
|
|
286
|
+
"approve(address,uint256)": "0x095ea7b3",
|
|
287
|
+
"transfer(address,uint256)": "0xa9059cbb",
|
|
288
|
+
"transferFrom(address,address,uint256)": "0x23b872dd",
|
|
289
|
+
# WETH
|
|
290
|
+
"withdraw(uint256)": "0x2e1a7d4d",
|
|
291
|
+
"deposit()": "0xd0e30db0",
|
|
292
|
+
# Uniswap V2
|
|
293
|
+
"getPair(address,address)": "0xe6a43905",
|
|
294
|
+
"factory()": "0xc45a0155",
|
|
295
|
+
"token0()": "0x0dfe1681",
|
|
296
|
+
"token1()": "0xd21220a7",
|
|
297
|
+
"getReserves()": "0x0902f1ac",
|
|
298
|
+
"mint(address)": "0x6c904f02",
|
|
299
|
+
"burn(address)": "0xf429f9e7",
|
|
300
|
+
"swap(uint256,uint256,address,bytes)": "0x022c0d9f",
|
|
301
|
+
"skim(address)": "0xbc25cf77",
|
|
302
|
+
"sync()": "0xfff6cae9",
|
|
303
|
+
# Uniswap V3 NonfungiblePositionManager
|
|
304
|
+
"positions(uint256)": "0x99fbab88",
|
|
305
|
+
"tokenOfOwnerByIndex(address,uint256)": "0x2f745c59",
|
|
306
|
+
# Uniswap V3 Pool
|
|
307
|
+
"getPool(address,address,uint24)": "0x1698ee82",
|
|
308
|
+
"slot0()": "0x3850c7bd",
|
|
309
|
+
"liquidity()": "0x455a4812",
|
|
310
|
+
"feeGrowthGlobal0X128()": "0xf3058399",
|
|
311
|
+
"feeGrowthGlobal1X128()": "0x463e4d99",
|
|
312
|
+
"tickSpacing()": "0xd0c93a7c",
|
|
313
|
+
# Uniswap V3 actions
|
|
314
|
+
"mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))": "0x88316456",
|
|
315
|
+
"increaseLiquidity((uint256,uint256,uint256,uint256,uint256,uint256))": "0x219f5d17",
|
|
316
|
+
"decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))": "0x0c7e2c76",
|
|
317
|
+
"collect((uint256,address,uint128,uint128))": "0xfc6f7865",
|
|
318
|
+
"burn(uint256)": "0x42966c68",
|
|
319
|
+
# Multicall
|
|
320
|
+
"multicall(bytes32,bytes[])": "0xac9650d8",
|
|
321
|
+
"multicall(uint256,bytes[])": "0x5ae401dc",
|
|
322
|
+
# Uniswap V4
|
|
323
|
+
"getPoolId(address,address,address,bytes32)": None, # needs dynamic encoding
|
|
324
|
+
"getSlot0(bytes32)": None,
|
|
325
|
+
"getPosition(bytes32,address,int24,int24)": None,
|
|
326
|
+
"getLiquidity(bytes32)": None,
|
|
327
|
+
"currency0()": "0x3fc8cef3",
|
|
328
|
+
"currency1()": "0x8ee59feb",
|
|
329
|
+
"fee()": "0xddca3f43",
|
|
330
|
+
"hooks()": "0xb7b04879",
|
|
331
|
+
"parameters()": "0x6c2e9c33",
|
|
332
|
+
"poolManager()": "0x3a98ef39",
|
|
333
|
+
"nextTokenId()": "0x800f9a5b",
|
|
334
|
+
"modifyLiquidities(bytes,uint256)": "0x0b22dd98",
|
|
335
|
+
"settle()": "0x4b309806",
|
|
336
|
+
"take(address,address,uint256)": "0xa5d7c38c",
|
|
337
|
+
"clear(address,uint256,bool)": "0x524b9e89",
|
|
338
|
+
"sweep(address,address,uint256)": "0x49df728c",
|
|
339
|
+
"settleFor(address)": "0x2c398a6c",
|
|
340
|
+
"closeToken(uint256,address,address,bytes32)": None,
|
|
341
|
+
# V2 Router
|
|
342
|
+
"addLiquidity(address,address,uint256,uint256,uint256,uint256,address,uint256)": "0xe8e33700",
|
|
343
|
+
"removeLiquidity(address,address,uint256,uint256,uint256,address,uint256)": "0x21959967",
|
|
344
|
+
# V4 Position Manager
|
|
345
|
+
"getPositionLiquidity(uint256)": "0x2e3b7a7e",
|
|
346
|
+
"getPoolAndPositionInfo(uint256)": "0x3af6b530",
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def encode_selector(signature: str) -> str:
|
|
351
|
+
"""Return the 4-byte function selector for a given Solidity signature.
|
|
352
|
+
|
|
353
|
+
For standard signatures the selector is looked up from a built-in table.
|
|
354
|
+
For unknown signatures, a :pyexc:`ValueError` is raised.
|
|
355
|
+
"""
|
|
356
|
+
sel = _SELECTORS.get(signature)
|
|
357
|
+
if sel is None:
|
|
358
|
+
raise ValueError(f"Unknown function selector for '{signature}'. Add it to _SELECTORS or use a keccak library.")
|
|
359
|
+
return sel
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def encode_address(addr: str) -> str:
|
|
363
|
+
"""Left-pad address to 32 bytes (no 0x prefix)."""
|
|
364
|
+
return addr.lower().replace("0x", "").rjust(64, "0")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def encode_uint(val: int) -> str:
|
|
368
|
+
"""Encode uint256 to 32-byte hex (no 0x prefix)."""
|
|
369
|
+
return hex(val)[2:].rjust(64, "0")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def encode_int(val: int) -> str:
|
|
373
|
+
"""Encode int256 to 32-byte hex (no 0x prefix)."""
|
|
374
|
+
if val >= 0:
|
|
375
|
+
return hex(val)[2:].rjust(64, "0")
|
|
376
|
+
# Two's complement for negative
|
|
377
|
+
return hex(val + (1 << 256))[2:].rjust(64, "0")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def encode_bytes32(b: str) -> str:
|
|
381
|
+
"""Encode a bytes32 hex value (no 0x prefix)."""
|
|
382
|
+
return b.replace("0x", "").rjust(64, "0")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def decode_uint(hex_val: str) -> int:
|
|
386
|
+
"""Decode a uint256 from hex."""
|
|
387
|
+
return int(hex_val, 16) if isinstance(hex_val, str) else int(hex_val)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def decode_int256(hex_val: str) -> int:
|
|
391
|
+
"""Decode an int256 from hex (handles negative)."""
|
|
392
|
+
val = int(hex_val, 16) if isinstance(hex_val, str) else int(hex_val)
|
|
393
|
+
if val >= (1 << 255):
|
|
394
|
+
val -= (1 << 256)
|
|
395
|
+
return val
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def decode_address(hex_val: str) -> str:
|
|
399
|
+
"""Decode an address from a 32-byte hex return value."""
|
|
400
|
+
clean = hex_val.replace("0x", "")[-40:]
|
|
401
|
+
return "0x" + clean
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def decode_string(hex_val: str) -> str:
|
|
405
|
+
"""Decode a dynamic string from ABI-encoded hex return."""
|
|
406
|
+
clean = hex_val.replace("0x", "")
|
|
407
|
+
if len(clean) < 128:
|
|
408
|
+
return ""
|
|
409
|
+
# First 32 bytes = offset (should be 0x20 for basic string)
|
|
410
|
+
# Next 32 bytes = length
|
|
411
|
+
length = int(clean[64:128], 16)
|
|
412
|
+
# Then the string bytes
|
|
413
|
+
hex_str = clean[128:128 + length * 2]
|
|
414
|
+
return bytes.fromhex(hex_str).decode("utf-8", errors="replace")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def read_erc20_decimals(token_address: str, rpc_url: str) -> int:
|
|
418
|
+
"""Read ERC-20 decimals() from chain."""
|
|
419
|
+
data = _SELECTORS["decimals()"]
|
|
420
|
+
raw = eth_call(token_address, data, rpc_url)
|
|
421
|
+
return decode_uint(raw)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def read_erc20_symbol(token_address: str, rpc_url: str) -> str:
|
|
425
|
+
"""Read ERC-20 symbol() from chain."""
|
|
426
|
+
data = _SELECTORS["symbol()"]
|
|
427
|
+
raw = eth_call(token_address, data, rpc_url)
|
|
428
|
+
try:
|
|
429
|
+
return decode_string(raw)
|
|
430
|
+
except Exception:
|
|
431
|
+
return raw.replace("0x", "")[:8] # fallback for short symbol
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def build_calldata(selector_or_sig: str, *args: str) -> str:
|
|
435
|
+
"""Build calldata from a function selector/signature and hex-encoded arguments.
|
|
436
|
+
|
|
437
|
+
Parameters
|
|
438
|
+
----------
|
|
439
|
+
selector_or_sig : str
|
|
440
|
+
Either a 0x-prefixed 4-byte selector, or a Solidity signature like
|
|
441
|
+
``"approve(address,uint256)"``.
|
|
442
|
+
*args : str
|
|
443
|
+
ABI-encoded argument values as hex strings (32 bytes each, no 0x prefix).
|
|
444
|
+
|
|
445
|
+
Returns
|
|
446
|
+
-------
|
|
447
|
+
str
|
|
448
|
+
Full calldata (0x-prefixed).
|
|
449
|
+
"""
|
|
450
|
+
if selector_or_sig.startswith("0x") and len(selector_or_sig) == 10:
|
|
451
|
+
selector = selector_or_sig
|
|
452
|
+
else:
|
|
453
|
+
selector = encode_selector(selector_or_sig)
|
|
454
|
+
return "0x" + selector.replace("0x", "") + "".join(a.replace("0x", "") for a in args)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def eth_fee_history(block_count: int, newest_block: str, rpc_url: str, reward_percentiles: list[int] | None = None) -> dict[str, Any]:
|
|
458
|
+
"""Call eth_feeHistory."""
|
|
459
|
+
params = [hex(block_count), newest_block]
|
|
460
|
+
if reward_percentiles is not None:
|
|
461
|
+
params.append(reward_percentiles)
|
|
462
|
+
return _json_rpc("eth_feeHistory", params, rpc_url)
|