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,185 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ from uniswap_autopilot.common.common import (
11
+ dump_json,
12
+ get_v4_position_manager_address,
13
+ load_local_env,
14
+ normalize_chain,
15
+ )
16
+ from uniswap_autopilot.execute._internal.rpc import (
17
+ decode_address, decode_uint, encode_address, encode_selector,
18
+ encode_uint, eth_call, resolve_rpc_url,
19
+ )
20
+ from uniswap_autopilot.lp.v3.tick import tick_to_price
21
+
22
+
23
+ def _parse_position_info(packed: int) -> dict[str, Any]:
24
+ # V4 PositionInfo packing: bits 0-23 = tickUpper, bits 24-47 = tickLower, bit 56 = hasSubscriber
25
+ tick_upper = packed & 0xFFFFFF
26
+ if tick_upper >= 0x800000:
27
+ tick_upper -= 0x1000000
28
+ tick_lower = (packed >> 24) & 0xFFFFFF
29
+ if tick_lower >= 0x800000:
30
+ tick_lower -= 0x1000000
31
+ has_subscriber = bool((packed >> 56) & 0x1)
32
+ return {"tickLower": tick_lower, "tickUpper": tick_upper, "hasSubscriber": has_subscriber}
33
+
34
+
35
+ def query_v4_position(
36
+ token_id: int,
37
+ chain_name: str,
38
+ rpc_url: str | None = None,
39
+ ) -> dict[str, Any]:
40
+ chain = normalize_chain(chain_name)
41
+ rpc, _ = resolve_rpc_url(rpc_url, chain.chain_id)
42
+ if not rpc:
43
+ raise RuntimeError(f"RPC URL not configured for {chain_name}")
44
+
45
+ pm = get_v4_position_manager_address(chain_name)
46
+
47
+ # getPositionLiquidity(uint256)
48
+ liq_sel = encode_selector("getPositionLiquidity(uint256)")
49
+ liq_data = liq_sel + encode_uint(token_id).replace("0x", "")
50
+ raw_liq = eth_call(pm, liq_data, rpc)
51
+ liquidity = decode_uint(raw_liq)
52
+
53
+ # getPoolAndPositionInfo(uint256) returns (address,address,address,address,uint24,uint256,uint256)
54
+ info_sel = encode_selector("getPoolAndPositionInfo(uint256)")
55
+ info_data = info_sel + encode_uint(token_id).replace("0x", "")
56
+ raw_info = eth_call(pm, info_data, rpc)
57
+ clean = raw_info.replace("0x", "")
58
+
59
+ currency0 = decode_address("0x" + clean[0:64])
60
+ currency1 = decode_address("0x" + clean[64:128])
61
+ hooks = decode_address("0x" + clean[128:192])
62
+ pool_manager = decode_address("0x" + clean[192:256])
63
+ fee = decode_uint("0x" + clean[256:320])
64
+ parameters = decode_uint("0x" + clean[320:384])
65
+ position_info_packed = decode_uint("0x" + clean[384:448])
66
+ tick_spacing = parameters >> 24
67
+
68
+ pos_info = _parse_position_info(position_info_packed)
69
+
70
+ from uniswap_autopilot.common.common import resolve_token
71
+ try:
72
+ tok0 = resolve_token(chain, currency0, rpc)
73
+ decimals0 = tok0["decimals"]
74
+ symbol0 = tok0.get("symbol", currency0)
75
+ except Exception:
76
+ decimals0 = 18
77
+ symbol0 = currency0[:10]
78
+ try:
79
+ tok1 = resolve_token(chain, currency1, rpc)
80
+ decimals1 = tok1["decimals"]
81
+ symbol1 = tok1.get("symbol", currency1)
82
+ except Exception:
83
+ decimals1 = 18
84
+ symbol1 = currency1[:10]
85
+
86
+ current_tick = None
87
+ try:
88
+ from uniswap_autopilot.lp.v4.pool import compute_pool_id, query_v4_slot0
89
+ from uniswap_autopilot.common.common import get_v4_state_view_address
90
+ state_view = get_v4_state_view_address(chain_name)
91
+ pool_id = compute_pool_id(currency0, currency1, hooks, pool_manager, fee, tick_spacing)
92
+ slot0 = query_v4_slot0(state_view, pool_id, rpc)
93
+ current_tick = slot0["tick"]
94
+ except Exception:
95
+ pass
96
+
97
+ current_price = tick_to_price(current_tick, decimals0, decimals1) if current_tick is not None else None
98
+ in_range = pos_info["tickLower"] < current_tick < pos_info["tickUpper"] if current_tick is not None else None
99
+
100
+ return {
101
+ "action": "v4_position",
102
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
103
+ "tokenId": token_id,
104
+ "currency0": {"address": currency0, "symbol": symbol0, "decimals": decimals0},
105
+ "currency1": {"address": currency1, "symbol": symbol1, "decimals": decimals1},
106
+ "fee": fee,
107
+ "tickSpacing": tick_spacing,
108
+ "hooks": hooks,
109
+ "tickLower": pos_info["tickLower"],
110
+ "tickUpper": pos_info["tickUpper"],
111
+ "liquidity": str(liquidity),
112
+ "currentTick": current_tick,
113
+ "currentPrice": current_price,
114
+ "inRange": in_range,
115
+ }
116
+
117
+
118
+ def query_v4_positions_by_owner(
119
+ owner: str,
120
+ chain_name: str,
121
+ rpc_url: str | None = None,
122
+ ) -> list[int]:
123
+ chain = normalize_chain(chain_name)
124
+ rpc, _ = resolve_rpc_url(rpc_url, chain.chain_id)
125
+ if not rpc:
126
+ raise RuntimeError(f"RPC URL not configured for {chain_name}")
127
+
128
+ pm = get_v4_position_manager_address(chain_name)
129
+
130
+ bal_sel = encode_selector("balanceOf(address)")
131
+ bal_data = bal_sel + encode_address(owner).replace("0x", "")
132
+ raw_bal = eth_call(pm, bal_data, rpc)
133
+ count = decode_uint(raw_bal)
134
+
135
+ token_ids: list[int] = []
136
+ tobi_sel = encode_selector("tokenOfOwnerByIndex(address,uint256)")
137
+ for i in range(count):
138
+ data = tobi_sel + encode_address(owner).replace("0x", "") + encode_uint(i).replace("0x", "")
139
+ raw_tid = eth_call(pm, data, rpc)
140
+ token_ids.append(decode_uint(raw_tid))
141
+ return token_ids
142
+
143
+
144
+ def main() -> None:
145
+ parser = argparse.ArgumentParser(description="Uniswap V4 position queries")
146
+ sub = parser.add_subparsers(dest="command")
147
+
148
+ i = sub.add_parser("info", help="Query V4 position by token ID")
149
+ i.add_argument("--chain", required=True)
150
+ i.add_argument("--token-id", type=int, required=True)
151
+ i.add_argument("--rpc-url")
152
+ i.add_argument("--output")
153
+
154
+ o = sub.add_parser("owner", help="List V4 position token IDs by owner")
155
+ o.add_argument("--chain", required=True)
156
+ o.add_argument("--owner", required=True)
157
+ o.add_argument("--rpc-url")
158
+ o.add_argument("--output")
159
+
160
+ args = parser.parse_args()
161
+ load_local_env()
162
+
163
+ if args.command == "info":
164
+ result = query_v4_position(args.token_id, args.chain, args.rpc_url)
165
+ t0 = result["currency0"]["symbol"]
166
+ t1 = result["currency1"]["symbol"]
167
+ ir = "in-range" if result.get("inRange") else "out-of-range" if result.get("inRange") is False else "?"
168
+ print(f"V4 Position #{args.token_id}: {t0}/{t1} fee={result['fee']} {ir}")
169
+ print(f" Range: [{result['tickLower']}, {result['tickUpper']}] Liquidity: {result['liquidity']}")
170
+ if args.output:
171
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
172
+ dump_json(result)
173
+ elif args.command == "owner":
174
+ chain = normalize_chain(args.chain)
175
+ token_ids = query_v4_positions_by_owner(args.owner, args.chain, args.rpc_url)
176
+ print(f"Found {len(token_ids)} V4 positions for {args.owner} on {chain.key}")
177
+ for tid in token_ids:
178
+ print(f" #{tid}")
179
+ dump_json({"action": "v4_positions_by_owner", "chain": chain.key, "owner": args.owner, "tokenIds": token_ids})
180
+ else:
181
+ parser.print_help()
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
@@ -0,0 +1,371 @@
1
+ """Risk-control policy engine — declarative rules, state-machine integration.
2
+
3
+ A ``policy.yaml`` (or JSON) file defines per-project risk limits. The engine
4
+ loads the file, validates a trade *context* dict against the rules, and returns
5
+ a ``CheckResult`` indicating whether the trade should proceed, be warned about,
6
+ or be rejected outright.
7
+
8
+ Designed to sit between the ``PREFLIGHT`` and ``SIGNED`` states in the trade
9
+ execution state machine:
10
+
11
+ PREFLIGHT → policy.check() → pass → transition(SIGNED)
12
+ → reject → transition(FAILED)
13
+
14
+ Policy file resolution (first match wins):
15
+
16
+ 1. Explicit *path* argument to ``load_policy()``
17
+ 2. ``POLICY_FILE`` environment variable
18
+ 3. ``~/.stageforge/policy.yaml``
19
+ 4. ``~/.stageforge/policy.json``
20
+
21
+ Minimal policy file example (YAML):
22
+
23
+ global:
24
+ max_amount: 1000 # USD equivalent
25
+ allowed_chains:
26
+ - ethereum
27
+ - polygon
28
+ - base
29
+ blacklist_addresses: []
30
+ whitelist_addresses: [] # empty = allow all
31
+
32
+ evm-wallet-scanner:
33
+ max_amount: 500
34
+
35
+ Rules cascade: project-specific overrides merge on top of ``global``.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ from dataclasses import dataclass, field
43
+ from decimal import Decimal, InvalidOperation
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ try:
48
+ import yaml as _yaml # type: ignore[import-untyped]
49
+ except ImportError: # pragma: no cover
50
+ _yaml = None # YAML optional; JSON always works
51
+
52
+ _DEFAULT_PROJECT = __name__.split(".")[0].replace("_", "-")
53
+
54
+
55
+ # ── Data structures ──────────────────────────────────────────────────────────
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Violation:
60
+ """A single rule violation or warning."""
61
+
62
+ rule: str
63
+ message: str
64
+ severity: str = "reject" # "reject" | "warn"
65
+
66
+
67
+ @dataclass
68
+ class CheckResult:
69
+ """Outcome of ``check()``."""
70
+
71
+ allowed: bool = True
72
+ violations: list[Violation] = field(default_factory=list)
73
+ warnings: list[Violation] = field(default_factory=list)
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ return {
77
+ "allowed": self.allowed,
78
+ "violations": [{"rule": v.rule, "message": v.message} for v in self.violations],
79
+ "warnings": [{"rule": w.rule, "message": w.message} for w in self.warnings],
80
+ }
81
+
82
+
83
+ @dataclass
84
+ class Policy:
85
+ """Resolved rules for a single project (global + project overlay)."""
86
+
87
+ max_amount: Decimal | None = None
88
+ allowed_chains: list[str] | None = None
89
+ blacklist_addresses: list[str] = field(default_factory=list)
90
+ whitelist_addresses: list[str] = field(default_factory=list)
91
+ max_slippage_bps: int | None = None
92
+ max_gas_price_gwei: Decimal | None = None
93
+ extra: dict[str, Any] = field(default_factory=dict)
94
+
95
+
96
+ # ── Loading ──────────────────────────────────────────────────────────────────
97
+
98
+
99
+ def _find_policy_file(explicit: str | None = None) -> Path | None:
100
+ """Resolve the policy file path (first match wins)."""
101
+ if explicit:
102
+ p = Path(explicit)
103
+ if p.is_file():
104
+ return p
105
+ return None
106
+
107
+ env = (os.environ.get("POLICY_FILE") or "").strip()
108
+ if env:
109
+ p = Path(env)
110
+ if p.is_file():
111
+ return p
112
+ return None
113
+
114
+ base = Path.home() / ".stageforge"
115
+ for name in ("policy.yaml", "policy.yml", "policy.json"):
116
+ p = base / name
117
+ if p.is_file():
118
+ return p
119
+ return None
120
+
121
+
122
+ def _parse_file(path: Path) -> dict[str, Any]:
123
+ """Read a YAML or JSON policy file and return a raw dict."""
124
+ text = path.read_text(encoding="utf-8")
125
+ if path.suffix in (".yaml", ".yml"):
126
+ if _yaml is None:
127
+ raise ImportError(
128
+ "PyYAML is required to load .yaml policy files. "
129
+ "Install it with: pip install pyyaml"
130
+ )
131
+ return _yaml.safe_load(text) or {} # type: ignore[no-any-return]
132
+ return json.loads(text)
133
+
134
+
135
+ def _decimal_or_none(val: Any) -> Decimal | None:
136
+ if val is None:
137
+ return None
138
+ try:
139
+ return Decimal(str(val))
140
+ except (InvalidOperation, ValueError):
141
+ return None
142
+
143
+
144
+ def _build_policy(raw: dict[str, Any]) -> Policy:
145
+ """Build a ``Policy`` from a raw rule dict (global or project section)."""
146
+ known_keys = {
147
+ "max_amount", "allowed_chains", "blacklist_addresses",
148
+ "whitelist_addresses", "max_slippage_bps", "max_gas_price_gwei",
149
+ }
150
+ extra = {k: v for k, v in raw.items() if k not in known_keys}
151
+
152
+ bl = raw.get("blacklist_addresses") or []
153
+ wl = raw.get("whitelist_addresses") or []
154
+
155
+ return Policy(
156
+ max_amount=_decimal_or_none(raw.get("max_amount")),
157
+ allowed_chains=raw.get("allowed_chains"),
158
+ blacklist_addresses=[a.lower() for a in bl],
159
+ whitelist_addresses=[a.lower() for a in wl],
160
+ max_slippage_bps=raw.get("max_slippage_bps"),
161
+ max_gas_price_gwei=_decimal_or_none(raw.get("max_gas_price_gwei")),
162
+ extra=extra,
163
+ )
164
+
165
+
166
+ def _merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
167
+ """Shallow merge *overlay* on top of *base* (overlay wins for non-None)."""
168
+ merged = dict(base)
169
+ for k, v in overlay.items():
170
+ if v is not None:
171
+ merged[k] = v
172
+ return merged
173
+
174
+
175
+ def load_policy(
176
+ path: str | None = None,
177
+ *,
178
+ project: str = _DEFAULT_PROJECT,
179
+ ) -> Policy:
180
+ """Load and resolve the policy for *project*.
181
+
182
+ Returns a ``Policy`` with ``global`` rules as base and project-specific
183
+ overrides applied on top. If no policy file is found, returns a
184
+ permissive default (no limits).
185
+ """
186
+ fp = _find_policy_file(path)
187
+ if fp is None:
188
+ return Policy()
189
+
190
+ raw = _parse_file(fp)
191
+ global_rules: dict[str, Any] = raw.get("global", {})
192
+ project_rules: dict[str, Any] = raw.get(project, {})
193
+
194
+ merged = _merge(global_rules, project_rules)
195
+ return _build_policy(merged)
196
+
197
+
198
+ # ── Checks ───────────────────────────────────────────────────────────────────
199
+
200
+
201
+ def check(
202
+ policy: Policy,
203
+ context: dict[str, Any],
204
+ ) -> CheckResult:
205
+ """Validate a trade *context* against *policy* rules.
206
+
207
+ Expected *context* keys (all optional — absent keys skip the check):
208
+
209
+ amount float/str/Decimal — trade amount (USD or native units)
210
+ chain str — chain name, e.g. "ethereum"
211
+ sender str — sender address
212
+ receiver str — receiver address
213
+ spender str — spender / operator address
214
+ slippage_bps int — slippage in basis points
215
+ gas_price_gwei float/str — gas price in gwei
216
+
217
+ Returns a ``CheckResult`` with ``allowed=False`` if any hard violation is
218
+ found. Warnings do not block the trade.
219
+ """
220
+ result = CheckResult()
221
+
222
+ _check_max_amount(policy, context, result)
223
+ _check_allowed_chains(policy, context, result)
224
+ _check_blacklist(policy, context, result)
225
+ _check_whitelist(policy, context, result)
226
+ _check_max_slippage(policy, context, result)
227
+ _check_max_gas_price(policy, context, result)
228
+
229
+ if result.violations:
230
+ result.allowed = False
231
+
232
+ return result
233
+
234
+
235
+ # ── Individual rule checks ───────────────────────────────────────────────────
236
+
237
+
238
+ def _check_max_amount(policy: Policy, ctx: dict[str, Any], res: CheckResult) -> None:
239
+ if policy.max_amount is None:
240
+ return
241
+ raw = ctx.get("amount")
242
+ if raw is None:
243
+ return
244
+ try:
245
+ amt = Decimal(str(raw))
246
+ except (InvalidOperation, ValueError):
247
+ return
248
+ if amt > policy.max_amount:
249
+ res.violations.append(Violation(
250
+ rule="max_amount",
251
+ message=f"amount {amt} exceeds limit {policy.max_amount}",
252
+ ))
253
+
254
+
255
+ def _check_allowed_chains(policy: Policy, ctx: dict[str, Any], res: CheckResult) -> None:
256
+ if not policy.allowed_chains:
257
+ return
258
+ chain = ctx.get("chain")
259
+ if chain is None:
260
+ return
261
+ if chain.lower() not in [c.lower() for c in policy.allowed_chains]:
262
+ res.violations.append(Violation(
263
+ rule="allowed_chains",
264
+ message=f"chain {chain!r} not in allowed list {policy.allowed_chains}",
265
+ ))
266
+
267
+
268
+ def _check_blacklist(policy: Policy, ctx: dict[str, Any], res: CheckResult) -> None:
269
+ if not policy.blacklist_addresses:
270
+ return
271
+ for key in ("sender", "receiver", "spender"):
272
+ addr = ctx.get(key)
273
+ if addr and addr.lower() in policy.blacklist_addresses:
274
+ res.violations.append(Violation(
275
+ rule="blacklist_addresses",
276
+ message=f"{key} {addr} is blacklisted",
277
+ ))
278
+
279
+
280
+ def _check_whitelist(policy: Policy, ctx: dict[str, Any], res: CheckResult) -> None:
281
+ if not policy.whitelist_addresses:
282
+ return
283
+ for key in ("sender", "receiver", "spender"):
284
+ addr = ctx.get(key)
285
+ if addr and addr.lower() not in policy.whitelist_addresses:
286
+ res.warnings.append(Violation(
287
+ rule="whitelist_addresses",
288
+ message=f"{key} {addr} not in whitelist",
289
+ severity="warn",
290
+ ))
291
+
292
+
293
+ def _check_max_slippage(policy: Policy, ctx: dict[str, Any], res: CheckResult) -> None:
294
+ if policy.max_slippage_bps is None:
295
+ return
296
+ slippage = ctx.get("slippage_bps")
297
+ if slippage is None:
298
+ return
299
+ try:
300
+ val = int(slippage)
301
+ except (TypeError, ValueError):
302
+ return
303
+ if val > policy.max_slippage_bps:
304
+ res.violations.append(Violation(
305
+ rule="max_slippage_bps",
306
+ message=f"slippage {val} bps exceeds limit {policy.max_slippage_bps} bps",
307
+ ))
308
+
309
+
310
+ def _check_max_gas_price(policy: Policy, ctx: dict[str, Any], res: CheckResult) -> None:
311
+ if policy.max_gas_price_gwei is None:
312
+ return
313
+ gp = ctx.get("gas_price_gwei")
314
+ if gp is None:
315
+ return
316
+ try:
317
+ val = Decimal(str(gp))
318
+ except (InvalidOperation, ValueError):
319
+ return
320
+ if val > policy.max_gas_price_gwei:
321
+ res.warnings.append(Violation(
322
+ rule="max_gas_price_gwei",
323
+ message=f"gas price {val} gwei exceeds soft limit {policy.max_gas_price_gwei} gwei",
324
+ severity="warn",
325
+ ))
326
+
327
+
328
+ # ── Uniswap-specific checks ──────────────────────────────────────────────────
329
+
330
+
331
+ def _check_min_output_amount(policy: Policy, ctx: dict[str, Any], res: CheckResult) -> None:
332
+ min_out = policy.extra.get("min_output_amount")
333
+ if min_out is None:
334
+ return
335
+ output = ctx.get("output_amount")
336
+ if output is None:
337
+ return
338
+ try:
339
+ out_dec = Decimal(str(output))
340
+ min_dec = Decimal(str(min_out))
341
+ except (InvalidOperation, ValueError):
342
+ return
343
+ if min_dec <= 0:
344
+ return # 0 or negative means disabled
345
+ if out_dec < min_dec:
346
+ res.violations.append(Violation(
347
+ rule="min_output_amount",
348
+ message=f"output amount {out_dec} below minimum {min_dec}",
349
+ ))
350
+
351
+
352
+ def check_uniswap(
353
+ policy: Policy,
354
+ context: dict[str, Any],
355
+ ) -> CheckResult:
356
+ """Run shared + Uniswap-specific checks."""
357
+ result = check(policy, context)
358
+ _check_min_output_amount(policy, context, result)
359
+ if result.violations:
360
+ result.allowed = False
361
+ return result
362
+
363
+
364
+ __all__ = [
365
+ "CheckResult",
366
+ "Policy",
367
+ "Violation",
368
+ "check",
369
+ "check_uniswap",
370
+ "load_policy",
371
+ ]
@@ -0,0 +1,100 @@
1
+ """Price feed — multi-source token price lookup via CoinGecko free API.
2
+
3
+ Provides:
4
+ - ``get_price(chain, address, tier)`` — single token price
5
+ - ``get_prices_batch(pairs, tier)`` — batch price lookup
6
+ - ``get_eth_price()`` — current ETH/USD price
7
+
8
+ All functions are designed as drop-in replacements for the price_feed module
9
+ expected by ``analytics/position.py``, ``execute/_internal/tx.py``, and ``search/risk.py``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import urllib.request
16
+ from typing import Any
17
+ from urllib.error import HTTPError, URLError
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Chain name → CoinGecko platform id map
21
+ # ---------------------------------------------------------------------------
22
+ CHAIN_TO_PLATFORM: dict[str, str] = {
23
+ "ethereum": "ethereum",
24
+ "base": "base",
25
+ "arbitrum": "arbitrum-one",
26
+ "optimism": "optimistic-ethereum",
27
+ "polygon": "polygon-pos",
28
+ "bsc": "binance-smart-chain",
29
+ "avalanche": "avalanche",
30
+ "celo": "celo",
31
+ "world_chain": "world-chain",
32
+ "soneium": "soneium",
33
+ "linea": "linea",
34
+ "blast": "blast",
35
+ "zora": "zora",
36
+ "unichain": "unichain",
37
+ }
38
+
39
+ COINGECKO_API = "https://api.coingecko.com/api/v3"
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Internal helpers
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def _fetch(url: str, timeout: int = 15) -> Any:
47
+ """GET a JSON endpoint with retry logic."""
48
+ req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "uniswap-autopilot/1.0"})
49
+ try:
50
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
51
+ return json.loads(resp.read())
52
+ except (HTTPError, URLError, json.JSONDecodeError):
53
+ return None
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Public API
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def get_price(chain: str, address: str, tier: str = "normal") -> dict[str, Any] | None:
61
+ """Get current USD price for a single token.
62
+
63
+ Returns ``{"price": float, "symbol": str, ...}`` or ``None`` on failure.
64
+ """
65
+ platform = CHAIN_TO_PLATFORM.get(chain.lower())
66
+ if not platform:
67
+ return None
68
+
69
+ url = f"{COINGECKO_API}/simple/token_price/{platform}?contract_addresses={address}&vs_currencies=usd"
70
+ data = _fetch(url)
71
+ if not data or address.lower() not in data:
72
+ return None
73
+
74
+ price = data[address.lower()].get("usd")
75
+ if price is None:
76
+ return None
77
+
78
+ return {"price": float(price), "symbol": "", "source": "coingecko"}
79
+
80
+
81
+ def get_prices_batch(pairs: list[tuple[str, str]], tier: str = "normal") -> dict[str, dict[str, Any]]:
82
+ """Batch fetch prices for multiple (chain, address) pairs.
83
+
84
+ Returns ``{"chain:address": {"price": float}, ...}``.
85
+ """
86
+ results: dict[str, dict[str, Any]] = {}
87
+ for chain, address in pairs:
88
+ price_data = get_price(chain, address, tier)
89
+ if price_data:
90
+ results[f"{chain}:{address.lower()}"] = price_data
91
+ return results
92
+
93
+
94
+ def get_eth_price() -> float | None:
95
+ """Get current ETH/USD price."""
96
+ url = f"{COINGECKO_API}/simple/price?ids=ethereum&vs_currencies=usd"
97
+ data = _fetch(url)
98
+ if not data:
99
+ return None
100
+ return float(data.get("ethereum", {}).get("usd", 0)) or None
File without changes
File without changes