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