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,669 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ from uniswap_autopilot.common.common import (
13
+ decimal_to_base_units,
14
+ dump_json,
15
+ get_v4_pool_manager_address,
16
+ get_v4_position_manager_address,
17
+ get_v4_state_view_address,
18
+ load_local_env,
19
+ normalize_chain,
20
+ parse_amount,
21
+ resolve_token,
22
+ resolve_wallet_address,
23
+ sort_token_addresses,
24
+ )
25
+ from uniswap_autopilot.execute._internal.rpc import encode_uint
26
+ from uniswap_autopilot.lp.v3.tick import tick_to_sqrt_price_x96
27
+ from uniswap_autopilot.lp.v4.pool import compute_pool_id, query_v4_slot0
28
+
29
+ # V4 Action constants
30
+ INCREASE_LIQUIDITY = 0x00
31
+ DECREASE_LIQUIDITY = 0x01
32
+ MINT_POSITION = 0x02
33
+ BURN_POSITION = 0x03
34
+ COLLECT = 0x06
35
+ TAKE = 0x09
36
+ SETTLE = 0x0A
37
+ SETTLE_PAIR = 0x0B
38
+ TAKE_PAIR = 0x0D
39
+ CLOSE_CURRENCY = 0x12
40
+ SWEEP = 0x14
41
+
42
+ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
43
+ _UINT256_MOD = 2**256
44
+
45
+
46
+ def _encode_parameters(tick_spacing: int, hooks_registration: int = 0) -> int:
47
+ return (tick_spacing << 24) | hooks_registration
48
+
49
+
50
+ def _uint256(val: int) -> bytes:
51
+ return val.to_bytes(32, "big")
52
+
53
+
54
+ def _uint128(val: int) -> bytes:
55
+ return val.to_bytes(16, "big")
56
+
57
+
58
+ def _address(addr: str) -> bytes:
59
+ return int(addr, 16).to_bytes(32, "big")
60
+
61
+
62
+ def _encode_bytes(data: bytes) -> bytes:
63
+ length = len(data)
64
+ padded = data + b"\x00" * ((32 - length % 32) % 32)
65
+ return _uint256(length) + padded
66
+
67
+
68
+ def _encode_unlock_data(actions: list[int], params: list[bytes]) -> str:
69
+ """Build unlockData = abi.encode(bytes actions, bytes[] params)."""
70
+ if len(actions) != len(params):
71
+ raise ValueError("actions and params must have same length")
72
+
73
+ actions_bytes = bytes(actions)
74
+
75
+ # Encode bytes[] params
76
+ params_encoded = b""
77
+ offsets = []
78
+ # 1 word for array length, then N words for offsets, then data
79
+ data_start = 32 + 32 * len(params)
80
+ current_offset = data_start
81
+ for p in params:
82
+ offsets.append(current_offset)
83
+ padded_len = len(p) + ((32 - len(p) % 32) % 32)
84
+ current_offset += 32 + padded_len
85
+
86
+ params_encoded += _uint256(len(params))
87
+ for off in offsets:
88
+ params_encoded += _uint256(off)
89
+ for p in params:
90
+ params_encoded += _encode_bytes(p)
91
+
92
+ # Encode (bytes, bytes[])
93
+ # offset_actions = 64 (after two uint256 offset words)
94
+ # offset_params = 64 + size_of_actions_encoding
95
+ actions_padded = actions_bytes + b"\x00" * ((32 - len(actions_bytes) % 32) % 32)
96
+ actions_encoded = _uint256(len(actions_bytes)) + actions_padded
97
+ offset_actions = 64
98
+ offset_params = offset_actions + len(actions_encoded)
99
+
100
+ result = _uint256(offset_actions) + _uint256(offset_params) + actions_encoded + params_encoded
101
+ return "0x" + result.hex()
102
+
103
+
104
+ def _encode_pool_key(
105
+ currency0: str,
106
+ currency1: str,
107
+ hooks: str,
108
+ pool_manager: str,
109
+ fee: int,
110
+ tick_spacing: int,
111
+ ) -> bytes:
112
+ params = _encode_parameters(tick_spacing)
113
+ return (
114
+ _address(currency0)
115
+ + _address(currency1)
116
+ + _address(hooks)
117
+ + _address(pool_manager)
118
+ + _uint256(fee)
119
+ + _uint256(params)
120
+ )
121
+
122
+
123
+ def _encode_mint_params(
124
+ pool_key_bytes: bytes,
125
+ tick_lower: int,
126
+ tick_upper: int,
127
+ liquidity: int,
128
+ amount0_max: int,
129
+ amount1_max: int,
130
+ owner: str,
131
+ hook_data: bytes = b"",
132
+ ) -> bytes:
133
+ return (
134
+ pool_key_bytes
135
+ + _uint256(tick_lower % _UINT256_MOD)
136
+ + _uint256(tick_upper % _UINT256_MOD)
137
+ + _uint256(liquidity)
138
+ + _uint128(amount0_max).rjust(32, b"\x00")
139
+ + _uint128(amount1_max).rjust(32, b"\x00")
140
+ + _address(owner)
141
+ + _encode_bytes(hook_data)
142
+ )
143
+
144
+
145
+ def _encode_decrease_params(
146
+ token_id: int,
147
+ liquidity: int,
148
+ amount0_min: int,
149
+ amount1_min: int,
150
+ hook_data: bytes = b"",
151
+ ) -> bytes:
152
+ return (
153
+ _uint256(token_id)
154
+ + _uint256(liquidity)
155
+ + _uint128(amount0_min).rjust(32, b"\x00")
156
+ + _uint128(amount1_min).rjust(32, b"\x00")
157
+ + _encode_bytes(hook_data)
158
+ )
159
+
160
+
161
+ def _encode_settle_pair_params(currency0: str, currency1: str) -> bytes:
162
+ return _address(currency0) + _address(currency1)
163
+
164
+
165
+ def _encode_take_pair_params(currency0: str, currency1: str, recipient: str) -> bytes:
166
+ return _address(currency0) + _address(currency1) + _address(recipient)
167
+
168
+
169
+ def _encode_close_currency_params(currency: str, recipient: str) -> bytes:
170
+ return _address(currency) + _address(recipient)
171
+
172
+
173
+ def _apply_slippage(amount_str: str, slippage_pct: float) -> str:
174
+ from decimal import Decimal, ROUND_DOWN
175
+ amount = Decimal(amount_str)
176
+ factor = Decimal("1") - Decimal(str(slippage_pct)) / Decimal("100")
177
+ result = (amount * factor).to_integral_value(rounding=ROUND_DOWN)
178
+ return str(result)
179
+
180
+
181
+ def _get_liquidity_for_amount0(sqrt_a: int, sqrt_b: int, amount0: int) -> int:
182
+ if amount0 == 0:
183
+ return 0
184
+ return amount0 * sqrt_a * sqrt_b // ((sqrt_b - sqrt_a) * (2 ** 96))
185
+
186
+
187
+ def _get_liquidity_for_amount1(sqrt_a: int, sqrt_b: int, amount1: int) -> int:
188
+ if amount1 == 0:
189
+ return 0
190
+ return amount1 * (2 ** 96) // (sqrt_b - sqrt_a)
191
+
192
+
193
+ def _get_liquidity_for_amounts(
194
+ sqrt_price_x96: int,
195
+ sqrt_ratio_a_x96: int,
196
+ sqrt_ratio_b_x96: int,
197
+ amount0: int,
198
+ amount1: int,
199
+ ) -> int:
200
+ if sqrt_ratio_a_x96 > sqrt_ratio_b_x96:
201
+ sqrt_ratio_a_x96, sqrt_ratio_b_x96 = sqrt_ratio_b_x96, sqrt_ratio_a_x96
202
+ if sqrt_price_x96 <= sqrt_ratio_a_x96:
203
+ return _get_liquidity_for_amount0(sqrt_ratio_a_x96, sqrt_ratio_b_x96, amount0)
204
+ elif sqrt_price_x96 < sqrt_ratio_b_x96:
205
+ liq0 = _get_liquidity_for_amount0(sqrt_price_x96, sqrt_ratio_b_x96, amount0)
206
+ liq1 = _get_liquidity_for_amount1(sqrt_ratio_a_x96, sqrt_price_x96, amount1)
207
+ return min(liq0, liq1)
208
+ else:
209
+ return _get_liquidity_for_amount1(sqrt_ratio_a_x96, sqrt_ratio_b_x96, amount1)
210
+
211
+
212
+ def _get_amount0_delta(sqrt_a: int, sqrt_b: int, liquidity: int) -> int:
213
+ if liquidity == 0 or sqrt_b <= sqrt_a:
214
+ return 0
215
+ return liquidity * (2 ** 96) * (sqrt_b - sqrt_a) // (sqrt_a * sqrt_b)
216
+
217
+
218
+ def _get_amount1_delta(sqrt_a: int, sqrt_b: int, liquidity: int) -> int:
219
+ if liquidity == 0 or sqrt_b <= sqrt_a:
220
+ return 0
221
+ return liquidity * (sqrt_b - sqrt_a) // (2 ** 96)
222
+
223
+
224
+ def _compute_expected_amounts(
225
+ current_tick: int, tick_lower: int, tick_upper: int, liquidity: int,
226
+ ) -> tuple[int, int]:
227
+ """Estimate token amounts for a given liquidity in a tick range."""
228
+ if liquidity == 0:
229
+ return 0, 0
230
+ sqrt_price = tick_to_sqrt_price_x96(current_tick)
231
+ sqrt_a = tick_to_sqrt_price_x96(tick_lower)
232
+ sqrt_b = tick_to_sqrt_price_x96(tick_upper)
233
+ if current_tick <= tick_lower:
234
+ return _get_amount0_delta(sqrt_a, sqrt_b, liquidity), 0
235
+ elif current_tick < tick_upper:
236
+ return (
237
+ _get_amount0_delta(sqrt_price, sqrt_b, liquidity),
238
+ _get_amount1_delta(sqrt_a, sqrt_price, liquidity),
239
+ )
240
+ else:
241
+ return 0, _get_amount1_delta(sqrt_a, sqrt_b, liquidity)
242
+
243
+
244
+ def _build_modify_liquidities_calldata(unlock_data_hex: str, deadline: int) -> str:
245
+ # modifyLiquidities(bytes,uint256) — manual ABI encoding for dynamic bytes
246
+ sel = "0x0b22dd98" # modifyLiquidities(bytes,uint256)
247
+ data_bytes = bytes.fromhex(unlock_data_hex.replace("0x", ""))
248
+ data_len = len(data_bytes)
249
+ padded_len = data_len + ((32 - data_len % 32) % 32)
250
+ # slot0: offset to bytes = 64 (two 32-byte words: offset + uint256)
251
+ # slot1: uint256 deadline
252
+ # slot2: bytes length
253
+ # slot3+: bytes data padded to 32-byte boundary
254
+ encoded = (
255
+ sel.replace("0x", "")
256
+ + hex(64)[2:].rjust(64, "0") # offset to bytes
257
+ + encode_uint(deadline) # uint256 deadline
258
+ + hex(data_len)[2:].rjust(64, "0") # bytes length
259
+ + data_bytes.hex().ljust(padded_len * 2, "0") # padded data
260
+ )
261
+ return "0x" + encoded
262
+
263
+
264
+ def build_v4_mint_transaction(
265
+ chain_name: str,
266
+ token_a: str,
267
+ token_b: str,
268
+ fee: int,
269
+ tick_spacing: int,
270
+ tick_lower: int,
271
+ tick_upper: int,
272
+ amount_a: str,
273
+ amount_b: str,
274
+ slippage_pct: float = 0.5,
275
+ hooks: str = ZERO_ADDRESS,
276
+ recipient: str | None = None,
277
+ deadline_seconds: int = 600,
278
+ rpc_url: str | None = None,
279
+ request_only: bool = False,
280
+ ) -> dict[str, Any]:
281
+ chain = normalize_chain(chain_name)
282
+ pm = get_v4_position_manager_address(chain_name)
283
+ pool_manager = get_v4_pool_manager_address(chain_name)
284
+
285
+ tok_a = resolve_token(chain, token_a, rpc_url)
286
+ tok_b = resolve_token(chain, token_b, rpc_url)
287
+ c0, c1 = sort_token_addresses(tok_a["address"], tok_b["address"])
288
+
289
+ # Remap amounts: after sort, c0<=c1 but amounts must follow
290
+ if c0.lower() == tok_a["address"].lower():
291
+ decimals0, decimals1 = tok_a["decimals"], tok_b["decimals"]
292
+ amount0_human, amount1_human = amount_a, amount_b
293
+ else:
294
+ decimals0, decimals1 = tok_b["decimals"], tok_a["decimals"]
295
+ amount0_human, amount1_human = amount_b, amount_a
296
+
297
+ base0 = decimal_to_base_units(parse_amount(amount0_human), decimals0)
298
+ base1 = decimal_to_base_units(parse_amount(amount1_human), decimals1)
299
+
300
+ if tick_lower >= tick_upper:
301
+ raise ValueError(f"tick_lower ({tick_lower}) must be less than tick_upper ({tick_upper})")
302
+
303
+ wallet = recipient or os.environ.get("SECURE_WALLET_ADDRESS") or os.environ.get("HOT_WALLET_ADDRESS")
304
+ if not wallet:
305
+ raise ValueError("recipient is required")
306
+
307
+ # V4 requires client-side liquidity calculation
308
+ state_view = get_v4_state_view_address(chain_name)
309
+ pool_id = compute_pool_id(c0, c1, hooks, pool_manager, fee, tick_spacing)
310
+ slot0 = query_v4_slot0(state_view, pool_id, rpc_url) if rpc_url else None
311
+ sqrt_price_x96 = int(slot0["sqrtPriceX96"]) if slot0 else 0
312
+
313
+ sqrt_a = tick_to_sqrt_price_x96(tick_lower)
314
+ sqrt_b = tick_to_sqrt_price_x96(tick_upper)
315
+
316
+ if sqrt_price_x96 > 0:
317
+ liquidity = _get_liquidity_for_amounts(sqrt_price_x96, sqrt_a, sqrt_b, int(base0), int(base1))
318
+ else:
319
+ # Cannot compute without pool state; caller must ensure rpc_url is provided
320
+ raise ValueError("RPC URL is required for V4 mint (pool state needed for liquidity calculation)")
321
+
322
+ deadline = str(int(time.time()) + deadline_seconds)
323
+
324
+ pool_key_bytes = _encode_pool_key(c0, c1, hooks, pool_manager, fee, tick_spacing)
325
+ mint_params = _encode_mint_params(
326
+ pool_key_bytes, tick_lower, tick_upper, liquidity,
327
+ int(base0), int(base1), wallet,
328
+ )
329
+ settle_pair_params = _encode_settle_pair_params(c0, c1)
330
+
331
+ unlock_data = _encode_unlock_data(
332
+ [MINT_POSITION, SETTLE_PAIR],
333
+ [mint_params, settle_pair_params],
334
+ )
335
+
336
+ calldata = _build_modify_liquidities_calldata(unlock_data, int(deadline))
337
+
338
+ result: dict[str, Any] = {
339
+ "action": "v4_mint",
340
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
341
+ "fee": fee,
342
+ "tickSpacing": tick_spacing,
343
+ "hooks": hooks,
344
+ "tickLower": tick_lower,
345
+ "tickUpper": tick_upper,
346
+ "currency0": c0,
347
+ "currency1": c1,
348
+ "liquidity": str(liquidity),
349
+ "amount0Max": base0,
350
+ "amount1Max": base1,
351
+ "recipient": wallet,
352
+ "deadline": deadline,
353
+ "transaction": {
354
+ "kind": "v4_mint",
355
+ "to": pm,
356
+ "data": calldata,
357
+ "value": "0",
358
+ "chainId": chain.chain_id,
359
+ "from": wallet,
360
+ },
361
+ }
362
+ return result
363
+
364
+
365
+ def build_v4_decrease_liquidity_transaction(
366
+ chain_name: str,
367
+ token_id: int,
368
+ liquidity_pct: float,
369
+ slippage_pct: float = 0.5,
370
+ deadline_seconds: int = 600,
371
+ rpc_url: str | None = None,
372
+ wallet: str | None = None,
373
+ ) -> dict[str, Any]:
374
+ chain = normalize_chain(chain_name)
375
+ pm = get_v4_position_manager_address(chain_name)
376
+
377
+ owner = resolve_wallet_address(wallet) or os.environ.get("SECURE_WALLET_ADDRESS") or os.environ.get("HOT_WALLET_ADDRESS")
378
+ if not owner:
379
+ raise ValueError("wallet is required")
380
+
381
+ from uniswap_autopilot.lp.v4.position import query_v4_position
382
+ pos = query_v4_position(token_id, chain_name, rpc_url)
383
+ current_liq = int(pos["liquidity"])
384
+ if current_liq == 0:
385
+ raise ValueError(f"position {token_id} has no liquidity")
386
+
387
+ if liquidity_pct <= 0 or liquidity_pct > 100:
388
+ raise ValueError("liquidity_pct must be between 0 and 100")
389
+ remove_liq = int(current_liq * liquidity_pct / 100)
390
+ if remove_liq == 0:
391
+ raise ValueError("liquidity_pct results in 0 liquidity")
392
+
393
+ # Compute expected amounts from liquidity for proper slippage protection
394
+ current_tick = pos.get("currentTick")
395
+ tick_lower = pos["tickLower"]
396
+ tick_upper = pos["tickUpper"]
397
+ amount0_min = "0"
398
+ amount1_min = "0"
399
+ if current_tick is not None and slippage_pct > 0:
400
+ est0, est1 = _compute_expected_amounts(current_tick, tick_lower, tick_upper, remove_liq)
401
+ if est0 > 0:
402
+ amount0_min = _apply_slippage(str(est0), slippage_pct)
403
+ if est1 > 0:
404
+ amount1_min = _apply_slippage(str(est1), slippage_pct)
405
+
406
+ deadline = str(int(time.time()) + deadline_seconds)
407
+ c0 = pos["currency0"]["address"]
408
+ c1 = pos["currency1"]["address"]
409
+
410
+ decrease_params = _encode_decrease_params(token_id, remove_liq, int(amount0_min), int(amount1_min))
411
+ take_pair_params = _encode_take_pair_params(c0, c1, owner)
412
+
413
+ unlock_data = _encode_unlock_data(
414
+ [DECREASE_LIQUIDITY, TAKE_PAIR],
415
+ [decrease_params, take_pair_params],
416
+ )
417
+ calldata = _build_modify_liquidities_calldata(unlock_data, int(deadline))
418
+
419
+ return {
420
+ "action": "v4_decrease",
421
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
422
+ "tokenId": token_id,
423
+ "owner": owner,
424
+ "currentLiquidity": str(current_liq),
425
+ "removeLiquidity": str(remove_liq),
426
+ "removePct": liquidity_pct,
427
+ "deadline": deadline,
428
+ "transaction": {
429
+ "kind": "v4_decrease",
430
+ "to": pm,
431
+ "data": calldata,
432
+ "value": "0",
433
+ "chainId": chain.chain_id,
434
+ "from": owner,
435
+ },
436
+ }
437
+
438
+
439
+ def build_v4_collect_transaction(
440
+ chain_name: str,
441
+ token_id: int,
442
+ recipient: str | None = None,
443
+ rpc_url: str | None = None,
444
+ ) -> dict[str, Any]:
445
+ chain = normalize_chain(chain_name)
446
+ pm = get_v4_position_manager_address(chain_name)
447
+
448
+ wallet = recipient or os.environ.get("SECURE_WALLET_ADDRESS") or os.environ.get("HOT_WALLET_ADDRESS")
449
+ if not wallet:
450
+ raise ValueError("recipient is required")
451
+
452
+ from uniswap_autopilot.lp.v4.position import query_v4_position
453
+ pos = query_v4_position(token_id, chain_name, rpc_url)
454
+ c0 = pos["currency0"]["address"]
455
+ c1 = pos["currency1"]["address"]
456
+
457
+ deadline = str(int(time.time()) + 600)
458
+ decrease_params = _encode_decrease_params(token_id, 0, 0, 0)
459
+ close0_params = _encode_close_currency_params(c0, wallet)
460
+ close1_params = _encode_close_currency_params(c1, wallet)
461
+ sweep0_params = _address(c0) + _address(wallet)
462
+ sweep1_params = _address(c1) + _address(wallet)
463
+
464
+ unlock_data = _encode_unlock_data(
465
+ [DECREASE_LIQUIDITY, CLOSE_CURRENCY, CLOSE_CURRENCY, SWEEP, SWEEP],
466
+ [decrease_params, close0_params, close1_params, sweep0_params, sweep1_params],
467
+ )
468
+ calldata = _build_modify_liquidities_calldata(unlock_data, int(deadline))
469
+
470
+ return {
471
+ "action": "v4_collect",
472
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
473
+ "tokenId": token_id,
474
+ "recipient": wallet,
475
+ "deadline": deadline,
476
+ "transaction": {
477
+ "kind": "v4_collect",
478
+ "to": pm,
479
+ "data": calldata,
480
+ "value": "0",
481
+ "chainId": chain.chain_id,
482
+ "from": wallet,
483
+ },
484
+ }
485
+
486
+
487
+ def build_v4_increase_liquidity_transaction(
488
+ chain_name: str,
489
+ token_id: int,
490
+ amount0: str,
491
+ amount1: str,
492
+ slippage_pct: float = 0.5,
493
+ deadline_seconds: int = 600,
494
+ rpc_url: str | None = None,
495
+ wallet: str | None = None,
496
+ ) -> dict[str, Any]:
497
+ chain = normalize_chain(chain_name)
498
+ pm = get_v4_position_manager_address(chain_name)
499
+
500
+ owner = resolve_wallet_address(wallet) or os.environ.get("SECURE_WALLET_ADDRESS") or os.environ.get("HOT_WALLET_ADDRESS")
501
+ if not owner:
502
+ raise ValueError("wallet is required")
503
+
504
+ from uniswap_autopilot.lp.v4.position import query_v4_position
505
+ pos = query_v4_position(token_id, chain_name, rpc_url)
506
+ if pos["liquidity"] == "0":
507
+ raise ValueError(f"position {token_id} has no liquidity; use mint instead")
508
+
509
+ c0 = pos["currency0"]["address"]
510
+ c1 = pos["currency1"]["address"]
511
+ decimals0 = pos["currency0"]["decimals"]
512
+ decimals1 = pos["currency1"]["decimals"]
513
+
514
+ base0 = decimal_to_base_units(parse_amount(amount0), decimals0)
515
+ base1 = decimal_to_base_units(parse_amount(amount1), decimals1)
516
+
517
+ # V4 requires client-side liquidity calculation (same as mint)
518
+ current_tick = pos.get("currentTick")
519
+ if current_tick is None:
520
+ raise ValueError("RPC URL is required for V4 increase (pool state needed for liquidity calculation)")
521
+
522
+ tick_lower = pos["tickLower"]
523
+ tick_upper = pos["tickUpper"]
524
+ sqrt_price_x96 = tick_to_sqrt_price_x96(current_tick)
525
+ sqrt_a = tick_to_sqrt_price_x96(tick_lower)
526
+ sqrt_b = tick_to_sqrt_price_x96(tick_upper)
527
+ liquidity = _get_liquidity_for_amounts(sqrt_price_x96, sqrt_a, sqrt_b, int(base0), int(base1))
528
+
529
+ deadline = str(int(time.time()) + deadline_seconds)
530
+
531
+ increase_params = (
532
+ _uint256(token_id)
533
+ + _uint256(liquidity)
534
+ + _uint128(int(base0)).rjust(32, b"\x00")
535
+ + _uint128(int(base1)).rjust(32, b"\x00")
536
+ + _encode_bytes(b"")
537
+ )
538
+ settle_pair_params = _encode_settle_pair_params(c0, c1)
539
+
540
+ unlock_data = _encode_unlock_data(
541
+ [INCREASE_LIQUIDITY, SETTLE_PAIR],
542
+ [increase_params, settle_pair_params],
543
+ )
544
+ calldata = _build_modify_liquidities_calldata(unlock_data, int(deadline))
545
+
546
+ return {
547
+ "action": "v4_increase",
548
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
549
+ "tokenId": token_id,
550
+ "position": pos,
551
+ "owner": owner,
552
+ "currency0": c0,
553
+ "currency1": c1,
554
+ "liquidity": str(liquidity),
555
+ "amount0Max": base0,
556
+ "amount1Max": base1,
557
+ "deadline": deadline,
558
+ "transaction": {
559
+ "kind": "v4_increase",
560
+ "to": pm,
561
+ "data": calldata,
562
+ "value": "0",
563
+ "chainId": chain.chain_id,
564
+ "from": owner,
565
+ },
566
+ }
567
+
568
+
569
+ def main() -> None:
570
+ parser = argparse.ArgumentParser(description="Build Uniswap V4 LP transactions")
571
+ sub = parser.add_subparsers(dest="command")
572
+
573
+ m = sub.add_parser("mint", help="Mint a new V4 position")
574
+ m.add_argument("--chain", required=True)
575
+ m.add_argument("--token-a", required=True)
576
+ m.add_argument("--token-b", required=True)
577
+ m.add_argument("--fee", type=int, required=True)
578
+ m.add_argument("--tick-spacing", type=int, required=True)
579
+ m.add_argument("--tick-lower", type=int, required=True)
580
+ m.add_argument("--tick-upper", type=int, required=True)
581
+ m.add_argument("--amount-a", required=True)
582
+ m.add_argument("--amount-b", required=True)
583
+ m.add_argument("--slippage", type=float, default=0.5)
584
+ m.add_argument("--hooks", default=ZERO_ADDRESS)
585
+ m.add_argument("--recipient")
586
+ m.add_argument("--deadline", type=int, default=600)
587
+ m.add_argument("--rpc-url")
588
+ m.add_argument("--request-only", action="store_true")
589
+ m.add_argument("--output")
590
+
591
+ d = sub.add_parser("decrease", help="Decrease liquidity")
592
+ d.add_argument("--chain", required=True)
593
+ d.add_argument("--token-id", type=int, required=True)
594
+ d.add_argument("--liquidity-pct", type=float, required=True)
595
+ d.add_argument("--slippage", type=float, default=0.5)
596
+ d.add_argument("--deadline", type=int, default=600)
597
+ d.add_argument("--rpc-url")
598
+ d.add_argument("--output")
599
+
600
+ inc = sub.add_parser("increase", help="Increase liquidity")
601
+ inc.add_argument("--chain", required=True)
602
+ inc.add_argument("--token-id", type=int, required=True)
603
+ inc.add_argument("--amount0", required=True)
604
+ inc.add_argument("--amount1", required=True)
605
+ inc.add_argument("--slippage", type=float, default=0.5)
606
+ inc.add_argument("--deadline", type=int, default=600)
607
+ inc.add_argument("--rpc-url")
608
+ inc.add_argument("--output")
609
+
610
+ c = sub.add_parser("collect", help="Collect fees")
611
+ c.add_argument("--chain", required=True)
612
+ c.add_argument("--token-id", type=int, required=True)
613
+ c.add_argument("--recipient")
614
+ c.add_argument("--rpc-url")
615
+ c.add_argument("--output")
616
+
617
+ args = parser.parse_args()
618
+ load_local_env()
619
+
620
+ if args.command == "mint":
621
+ result = build_v4_mint_transaction(
622
+ chain_name=args.chain, token_a=args.token_a, token_b=args.token_b,
623
+ fee=args.fee, tick_spacing=args.tick_spacing,
624
+ tick_lower=args.tick_lower, tick_upper=args.tick_upper,
625
+ amount_a=args.amount_a, amount_b=args.amount_b,
626
+ slippage_pct=args.slippage, hooks=args.hooks,
627
+ recipient=args.recipient, deadline_seconds=args.deadline,
628
+ rpc_url=args.rpc_url, request_only=args.request_only,
629
+ )
630
+ print(f"V4 Mint tx: {args.token_a}/{args.token_b} fee={args.fee} ts={args.tick_spacing}")
631
+ if args.output:
632
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
633
+ dump_json(result)
634
+ elif args.command == "decrease":
635
+ result = build_v4_decrease_liquidity_transaction(
636
+ chain_name=args.chain, token_id=args.token_id,
637
+ liquidity_pct=args.liquidity_pct, slippage_pct=args.slippage,
638
+ deadline_seconds=args.deadline, rpc_url=args.rpc_url,
639
+ )
640
+ print(f"V4 Decrease tx: position #{args.token_id} remove {args.liquidity_pct}%")
641
+ if args.output:
642
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
643
+ dump_json(result)
644
+ elif args.command == "increase":
645
+ result = build_v4_increase_liquidity_transaction(
646
+ chain_name=args.chain, token_id=args.token_id,
647
+ amount0=args.amount0, amount1=args.amount1,
648
+ slippage_pct=args.slippage, deadline_seconds=args.deadline,
649
+ rpc_url=args.rpc_url,
650
+ )
651
+ print(f"V4 Increase tx: position #{args.token_id}")
652
+ if args.output:
653
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
654
+ dump_json(result)
655
+ elif args.command == "collect":
656
+ result = build_v4_collect_transaction(
657
+ chain_name=args.chain, token_id=args.token_id,
658
+ recipient=args.recipient, rpc_url=args.rpc_url,
659
+ )
660
+ print(f"V4 Collect tx: position #{args.token_id}")
661
+ if args.output:
662
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
663
+ dump_json(result)
664
+ else:
665
+ parser.print_help()
666
+
667
+
668
+ if __name__ == "__main__":
669
+ main()