refactor(V2): hyperliquid client da SDK a httpx + eth-account EIP-712 (parità V1)
Riscritto interamente HyperliquidClient su httpx puro + eth-account per la firma EIP-712 L1 (chainId 1337, phantom agent source 'a'/'b' per mainnet/testnet). Bit-parity verificata contro hyperliquid.utils.signing in test_signing_parity_with_canonical_sdk. 16 metodi pubblici, 26 test passanti. Aggiunte deps: eth-account, msgpack, eth-utils. hyperliquid-python-sdk ancora presente nel pyproject; rimossa nel sweep finale. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,16 @@ import pytest
|
||||
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
# Chiave privata fissa: rende deterministica la firma EIP-712 per i test write.
|
||||
DUMMY_PRIVATE_KEY = "0x" + "01" * 32
|
||||
DUMMY_WALLET = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" # derived from key above
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return HyperliquidClient(
|
||||
wallet_address="0xDeadBeef",
|
||||
private_key="0x" + "a" * 64,
|
||||
wallet_address=DUMMY_WALLET,
|
||||
private_key=DUMMY_PRIVATE_KEY,
|
||||
testnet=True,
|
||||
)
|
||||
|
||||
@@ -41,6 +45,13 @@ META_AND_CTX = [
|
||||
],
|
||||
]
|
||||
|
||||
META = {
|
||||
"universe": [
|
||||
{"name": "BTC", "maxLeverage": 50},
|
||||
{"name": "ETH", "maxLeverage": 25},
|
||||
]
|
||||
}
|
||||
|
||||
CLEARINGHOUSE_STATE = {
|
||||
"marginSummary": {
|
||||
"accountValue": "1500.0",
|
||||
@@ -65,6 +76,9 @@ CLEARINGHOUSE_STATE = {
|
||||
SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]}
|
||||
|
||||
|
||||
# ── Read endpoints ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||
httpx_mock.add_response(
|
||||
@@ -209,19 +223,263 @@ async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||
assert result["testnet"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_order_sdk_unavailable(client: HyperliquidClient):
|
||||
"""place_order raises RuntimeError when SDK is not available (mocked)."""
|
||||
import cerbero_mcp.exchanges.hyperliquid.client as mod
|
||||
# ── Write endpoints (signed via EIP-712) ───────────────────────
|
||||
|
||||
original = mod._SDK_AVAILABLE
|
||||
mod._SDK_AVAILABLE = False
|
||||
client._exchange = None
|
||||
try:
|
||||
result = await client.place_order("BTC", "buy", 0.1, price=50000.0)
|
||||
# Should return error dict or raise RuntimeError
|
||||
assert "error" in result or result.get("status") == "error"
|
||||
except RuntimeError as exc:
|
||||
assert "not installed" in str(exc).lower() or "sdk" in str(exc).lower()
|
||||
finally:
|
||||
mod._SDK_AVAILABLE = original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_order_limit(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||
"""Limit order: signs and POSTs to /exchange with correct payload shape."""
|
||||
# 1. /info type=meta per asset id
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json=META,
|
||||
)
|
||||
# 2. /exchange firmato
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
|
||||
json={
|
||||
"status": "ok",
|
||||
"response": {
|
||||
"type": "order",
|
||||
"data": {
|
||||
"statuses": [{"resting": {"oid": 9999}}],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result = await client.place_order(
|
||||
instrument="BTC", side="buy", amount=0.01, type="limit", price=50000.0
|
||||
)
|
||||
|
||||
# Verifica shape risposta normalizzata
|
||||
assert result["status"] == "ok"
|
||||
assert result["order_id"] == 9999
|
||||
|
||||
# Verifica request body al POST /exchange
|
||||
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
|
||||
assert len(requests) == 1
|
||||
import json as _json
|
||||
|
||||
body = _json.loads(requests[0].content)
|
||||
assert body["nonce"] > 0
|
||||
assert body["vaultAddress"] is None
|
||||
assert body["expiresAfter"] is None
|
||||
assert body["action"]["type"] == "order"
|
||||
assert body["action"]["grouping"] == "na"
|
||||
assert len(body["action"]["orders"]) == 1
|
||||
order = body["action"]["orders"][0]
|
||||
assert order["a"] == 0 # BTC è index 0 in META
|
||||
assert order["b"] is True # buy
|
||||
assert order["p"] == "50000"
|
||||
assert order["s"] == "0.01"
|
||||
assert order["r"] is False
|
||||
assert order["t"] == {"limit": {"tif": "Gtc"}}
|
||||
sig = body["signature"]
|
||||
assert set(sig.keys()) == {"r", "s", "v"}
|
||||
assert sig["r"].startswith("0x") and len(sig["r"]) == 66
|
||||
assert sig["s"].startswith("0x") and len(sig["s"]) == 66
|
||||
assert sig["v"] in (27, 28)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_order_market(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||
"""Market order: usa mark_price + buffer e tif=Ioc."""
|
||||
# market path: get_ticker → meta+ctxs, poi meta per asset id, poi /exchange
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json=META_AND_CTX,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json=META,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
|
||||
json={
|
||||
"status": "ok",
|
||||
"response": {
|
||||
"type": "order",
|
||||
"data": {
|
||||
"statuses": [{"filled": {"oid": 1, "totalSz": "0.01", "avgPx": "51500"}}],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result = await client.place_order(
|
||||
instrument="BTC", side="buy", amount=0.01, type="market"
|
||||
)
|
||||
assert result["status"] == "ok"
|
||||
assert result["filled_size"] == 0.01
|
||||
|
||||
import json as _json
|
||||
|
||||
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
|
||||
assert len(requests) == 1
|
||||
body = _json.loads(requests[0].content)
|
||||
order = body["action"]["orders"][0]
|
||||
assert order["t"] == {"limit": {"tif": "Ioc"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_order_stop_loss(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||
"""Stop-loss: usa trigger order type."""
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json=META,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
|
||||
json={
|
||||
"status": "ok",
|
||||
"response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 7}}]}},
|
||||
},
|
||||
)
|
||||
|
||||
result = await client.place_order(
|
||||
instrument="BTC",
|
||||
side="sell",
|
||||
amount=0.01,
|
||||
type="stop_loss",
|
||||
price=45000.0,
|
||||
reduce_only=True,
|
||||
)
|
||||
assert result["status"] == "ok"
|
||||
assert result["order_id"] == 7
|
||||
|
||||
import json as _json
|
||||
|
||||
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
|
||||
body = _json.loads(requests[0].content)
|
||||
order = body["action"]["orders"][0]
|
||||
assert order["r"] is True
|
||||
assert order["t"] == {
|
||||
"trigger": {"isMarket": True, "triggerPx": "45000", "tpsl": "sl"}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_order_unknown_asset(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||
"""Asset sconosciuto → error dict, niente POST /exchange."""
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json=META,
|
||||
)
|
||||
result = await client.place_order(
|
||||
instrument="DOGE", side="buy", amount=1.0, type="limit", price=0.1
|
||||
)
|
||||
assert "error" in result
|
||||
assert "DOGE" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_order(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||
"""Cancel: action.type=cancel con asset id + oid."""
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json=META,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
|
||||
json={"status": "ok", "response": {"type": "cancel", "data": {"statuses": ["success"]}}},
|
||||
)
|
||||
|
||||
result = await client.cancel_order("12345", "BTC")
|
||||
assert result["status"] == "ok"
|
||||
assert result["order_id"] == "12345"
|
||||
|
||||
import json as _json
|
||||
|
||||
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
|
||||
body = _json.loads(requests[0].content)
|
||||
assert body["action"]["type"] == "cancel"
|
||||
assert body["action"]["cancels"] == [{"a": 0, "o": 12345}]
|
||||
assert "r" in body["signature"] and "s" in body["signature"] and "v" in body["signature"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_position(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||
"""close_position: legge stato, calcola slippage, place IOC reduce-only."""
|
||||
# 1. clearinghouseState per direzione
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json=CLEARINGHOUSE_STATE,
|
||||
)
|
||||
# 2. get_ticker → metaAndAssetCtxs
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json=META_AND_CTX,
|
||||
)
|
||||
# 3. meta per asset id
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json=META,
|
||||
)
|
||||
# 4. /exchange
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
|
||||
json={
|
||||
"status": "ok",
|
||||
"response": {
|
||||
"type": "order",
|
||||
"data": {
|
||||
"statuses": [{"filled": {"oid": 5, "totalSz": "0.1", "avgPx": "47500"}}],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result = await client.close_position("BTC")
|
||||
assert result["status"] == "ok"
|
||||
assert result["asset"] == "BTC"
|
||||
|
||||
import json as _json
|
||||
|
||||
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
|
||||
body = _json.loads(requests[0].content)
|
||||
order = body["action"]["orders"][0]
|
||||
# Posizione long → side=sell per chiudere
|
||||
assert order["b"] is False
|
||||
assert order["r"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_position_no_position(
|
||||
httpx_mock: HTTPXMock, client: HyperliquidClient
|
||||
):
|
||||
"""close_position senza posizione aperta → error dict."""
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||
json={"assetPositions": [], "marginSummary": {}},
|
||||
)
|
||||
result = await client.close_position("BTC")
|
||||
assert "error" in result
|
||||
assert result["asset"] == "BTC"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signing_parity_with_canonical_sdk(client: HyperliquidClient):
|
||||
"""Sanity: la firma EIP-712 prodotta è bit-for-bit identica a quella che
|
||||
genererebbe il SDK ufficiale ``hyperliquid-python-sdk`` per lo stesso input.
|
||||
|
||||
Test isolato (no httpx) per garantire che la rimozione del SDK runtime
|
||||
non introduca regressioni di signing.
|
||||
"""
|
||||
from cerbero_mcp.exchanges.hyperliquid.client import _sign_l1_action
|
||||
|
||||
action = {"type": "cancel", "cancels": [{"a": 0, "o": 12345}]}
|
||||
nonce = 1700000000000
|
||||
sig = _sign_l1_action(DUMMY_PRIVATE_KEY, action, None, nonce, None, False)
|
||||
assert sig == {
|
||||
"r": "0xab1150f8d695e015a07e3f79983a0a2a4e58dedec071dfa4177a0761f37e0485",
|
||||
"s": "0x208cb6370e5e56a3cefa451538c1e0096b70777d2bde172c7afb1e77c4d28d20",
|
||||
"v": 28,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_idempotent(client: HyperliquidClient):
|
||||
"""``aclose`` può essere chiamato anche senza http client attivo."""
|
||||
await client.aclose()
|
||||
await client.aclose()
|
||||
|
||||
Reference in New Issue
Block a user