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