Skip to content

Commit 6c285be

Browse files
committed
config, readme, hyperliquid listener refactor
1 parent 1942884 commit 6c285be

File tree

12 files changed

+221
-173
lines changed

12 files changed

+221
-173
lines changed

apps/hip-3-pusher/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# HIP-3 Pusher
2+
3+
`hip-3-pusher` is intended to serve as an oracle updater for
4+
[HIP-3 markets](https://hyperliquid.gitbook.io/hyperliquid-docs/hyperliquid-improvement-proposals-hips/hip-3-builder-deployed-perpetuals).
5+
6+
Currently it:
7+
- Sources market data from Hyperliquid, Pyth Lazer, and Pythnet
8+
- Supports KMS for signing oracle updates
9+
- Provides telemetry to Pyth's internal observability system

apps/hip-3-pusher/pyproject.toml

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
[project]
22
name = "hip-3-pusher"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
description = "Hyperliquid HIP-3 market oracle pusher"
55
readme = "README.md"
6-
requires-python = ">=3.13"
6+
requires-python = "==3.13.*"
77
dependencies = [
8-
"asn1crypto>=1.5.1",
9-
"boto3>=1.40.31",
10-
"cryptography>=45.0.7",
11-
"hyperliquid-python-sdk>=0.19.0",
12-
"loguru>=0.7.3",
13-
"opentelemetry-exporter-prometheus>=0.58b0",
14-
"opentelemetry-sdk>=1.37.0",
15-
"prometheus-client>=0.23.1",
16-
"toml>=0.10.2",
17-
"websockets>=15.0.1",
8+
"asn1crypto~=1.5.1",
9+
"boto3~=1.40.34",
10+
"cryptography~=46.0.1",
11+
"hyperliquid-python-sdk~=0.19.0",
12+
"loguru~=0.7.3",
13+
"opentelemetry-exporter-prometheus~=0.58b0",
14+
"opentelemetry-sdk~=1.37.0",
15+
"prometheus-client~=0.23.1",
16+
"websockets~=15.0.1",
1817
]

apps/hip-3-pusher/src/config.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from pydantic import BaseModel
2+
3+
4+
class KMSConfig(BaseModel):
5+
enable_kms: bool
6+
aws_region_name: str
7+
key_path: str
8+
access_key_id_path: str
9+
secret_access_key_path: str
10+
11+
12+
class LazerConfig(BaseModel):
13+
lazer_urls: list[str]
14+
lazer_api_key: str
15+
base_feed_id: int
16+
base_feed_exponent: int
17+
quote_feed_id: int
18+
quote_feed_exponent: int
19+
20+
21+
class HermesConfig(BaseModel):
22+
hermes_urls: list[str]
23+
base_feed_id: str
24+
base_feed_exponent: int
25+
quote_feed_id: str
26+
quote_feed_exponent: int
27+
28+
29+
class HyperliquidConfig(BaseModel):
30+
hyperliquid_ws_urls: list[str]
31+
market_name: str
32+
market_symbol: str
33+
use_testnet: bool
34+
oracle_pusher_key_path: str
35+
publish_interval: float
36+
enable_publish: bool
37+
38+
39+
class Config(BaseModel):
40+
stale_price_threshold_seconds: int
41+
prometheus_port: int
42+
hyperliquid: HyperliquidConfig
43+
kms: KMSConfig
44+
lazer: LazerConfig
45+
hermes: HermesConfig

apps/hip-3-pusher/src/hermes_listener.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@
44
import time
55
import websockets
66

7+
from config import Config
78
from price_state import PriceState
89

910

1011
class HermesListener:
1112
"""
1213
Subscribe to Hermes price updates for needed feeds.
1314
"""
14-
def __init__(self, config, price_state: PriceState):
15-
self.hermes_urls = config["hermes"]["hermes_urls"]
16-
self.base_feed_id = config["hermes"]["base_feed_id"]
17-
self.quote_feed_id = config["hermes"]["quote_feed_id"]
15+
def __init__(self, config: Config, price_state: PriceState):
16+
self.hermes_urls = config.hermes.hermes_urls
17+
self.base_feed_id = config.hermes.base_feed_id
18+
self.quote_feed_id = config.hermes.quote_feed_id
1819
self.price_state = price_state
1920

2021
def get_subscribe_request(self):
Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,71 @@
1+
import asyncio
2+
import json
3+
import websockets
14
from loguru import logger
25
import time
36

4-
from hyperliquid.info import Info
5-
from hyperliquid.utils.constants import TESTNET_API_URL, MAINNET_API_URL
6-
7+
from config import Config
78
from price_state import PriceState
89

10+
# This will be in config, but note here.
11+
# Other RPC providers exist but so far we've seen their support is incomplete.
12+
HYPERLIQUID_MAINNET_WS_URL = "wss://api.hyperliquid.xyz/ws"
13+
HYPERLIQUID_TESTNET_WS_URL = "wss://api.hyperliquid-testnet.xyz/ws"
14+
915

1016
class HyperliquidListener:
1117
"""
1218
Subscribe to any relevant Hyperliquid websocket streams
1319
See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket
1420
"""
15-
def __init__(self, config: dict, price_state: PriceState):
16-
self.market_symbol = config["hyperliquid"]["market_symbol"]
17-
url = TESTNET_API_URL if config["hyperliquid"].get("use_testnet", True) else MAINNET_API_URL
18-
self.info = Info(base_url=url)
21+
def __init__(self, config: Config, price_state: PriceState):
22+
self.hyperliquid_ws_urls = config.hyperliquid.hyperliquid_ws_urls
23+
self.market_symbol = config.hyperliquid.market_symbol
1924
self.price_state = price_state
2025

21-
def subscribe(self):
22-
self.info.subscribe({"type": "activeAssetCtx", "coin": self.market_symbol}, self.on_activeAssetCtx)
23-
24-
def on_activeAssetCtx(self, message):
25-
"""
26-
Parse oraclePx and markPx from perp context update
27-
28-
:param message: activeAssetCtx websocket update message
29-
:return: None
30-
"""
31-
ctx = message["data"]["ctx"]
32-
self.price_state.hl_oracle_price = ctx["oraclePx"]
33-
self.price_state.hl_mark_price = ctx["markPx"]
34-
logger.debug("on_activeAssetCtx: oraclePx: {} marketPx: {}", self.price_state.hl_oracle_price, self.price_state.hl_mark_price)
35-
self.price_state.latest_hl_timestamp = time.time()
26+
def get_subscribe_request(self, asset):
27+
return {
28+
"method": "subscribe",
29+
"subscription": {"type": "activeAssetCtx", "coin": asset}
30+
}
31+
32+
async def subscribe_all(self):
33+
await asyncio.gather(*(self.subscribe_single(hyperliquid_ws_url) for hyperliquid_ws_url in self.hyperliquid_ws_urls))
34+
35+
async def subscribe_single(self, url):
36+
while True:
37+
try:
38+
await self.subscribe_single_inner(url)
39+
except websockets.ConnectionClosed:
40+
logger.error("Connection to {} closed; retrying", url)
41+
except Exception as e:
42+
logger.exception("Error on {}: {}", url, e)
43+
44+
async def subscribe_single_inner(self, url):
45+
async with websockets.connect(url) as ws:
46+
subscribe_request = self.get_subscribe_request(self.market_symbol)
47+
await ws.send(json.dumps(subscribe_request))
48+
logger.info("Sent subscribe request to {}", url)
49+
50+
# listen for updates
51+
async for message in ws:
52+
try:
53+
data = json.loads(message)
54+
if "ctx" not in message:
55+
# TODO: Should check subscription status response
56+
logger.debug("HL update without ctx: {}", message)
57+
continue
58+
self.parse_hyperliquid_ws_message(data)
59+
except json.JSONDecodeError as e:
60+
logger.error("Failed to decode JSON message: {}", e)
61+
62+
def parse_hyperliquid_ws_message(self, message):
63+
try:
64+
ctx = message["data"]["ctx"]
65+
self.price_state.hl_oracle_price = ctx["oraclePx"]
66+
self.price_state.hl_mark_price = ctx["markPx"]
67+
logger.debug("on_activeAssetCtx: oraclePx: {} marketPx: {}", self.price_state.hl_oracle_price,
68+
self.price_state.hl_mark_price)
69+
self.price_state.latest_hl_timestamp = time.time()
70+
except Exception as e:
71+
logger.error("parse_hyperliquid_ws_message error: message: {} e: {}", e)

apps/hip-3-pusher/src/kms_signer.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,20 @@
1010
from hyperliquid.utils.signing import get_timestamp_ms, action_hash, construct_phantom_agent, l1_payload
1111
from loguru import logger
1212

13+
from config import Config
14+
1315
SECP256K1_N_HALF = SECP256K1_N // 2
1416

1517

1618
class KMSSigner:
17-
def __init__(self, config):
18-
use_testnet = config["hyperliquid"]["use_testnet"]
19+
def __init__(self, config: Config):
20+
use_testnet = config.hyperliquid.use_testnet
1921
url = TESTNET_API_URL if use_testnet else MAINNET_API_URL
2022
self.oracle_publisher_exchange: Exchange = Exchange(wallet=None, base_url=url)
2123
self.client = self._init_client(config)
2224

2325
# Fetch public key once so we can derive address and check recovery id
24-
key_path = config["kms"]["key_path"]
26+
key_path = config.kms.key_path
2527
self.key_id = open(key_path, "r").read().strip()
2628
self.pubkey_der = self.client.get_public_key(KeyId=self.key_id)["PublicKey"]
2729
# Construct eth address to log
@@ -35,10 +37,10 @@ def __init__(self, config):
3537
logger.info("KMSSigner address: {}", self.address)
3638

3739
def _init_client(self, config):
38-
aws_region_name = config["kms"]["aws_region_name"]
39-
access_key_id_path = config["kms"]["access_key_id_path"]
40+
aws_region_name = config.kms.aws_region_name
41+
access_key_id_path = config.kms.access_key_id_path
4042
access_key_id = open(access_key_id_path, "r").read().strip()
41-
secret_access_key_path = config["kms"]["secret_access_key_path"]
43+
secret_access_key_path = config.kms.secret_access_key_path
4244
secret_access_key = open(secret_access_key_path, "r").read().strip()
4345

4446
return boto3.client(

apps/hip-3-pusher/src/lazer_listener.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@
44
import time
55
import websockets
66

7+
from config import Config
78
from price_state import PriceState
89

910

1011
class LazerListener:
1112
"""
1213
Subscribe to Lazer price updates for needed feeds.
1314
"""
14-
def __init__(self, config, price_state: PriceState):
15-
self.lazer_urls = config["lazer"]["lazer_urls"]
16-
self.api_key = config["lazer"]["lazer_api_key"]
17-
self.base_feed_id = config["lazer"]["base_feed_id"]
18-
self.quote_feed_id = config["lazer"]["quote_feed_id"]
15+
def __init__(self, config: Config, price_state: PriceState):
16+
self.lazer_urls = config.lazer.lazer_urls
17+
self.api_key = config.lazer.lazer_api_key
18+
self.base_feed_id = config.lazer.base_feed_id
19+
self.quote_feed_id = config.lazer.quote_feed_id
1920
self.price_state = price_state
2021

2122
def get_subscribe_request(self, subscription_id: int):
@@ -52,7 +53,7 @@ async def subscribe_single_inner(self, router_url):
5253
subscribe_request = self.get_subscribe_request(1)
5354

5455
await ws.send(json.dumps(subscribe_request))
55-
logger.info("Sent Lazer subscribe request to {}", self.lazer_urls[0])
56+
logger.info("Sent Lazer subscribe request to {}", router_url)
5657

5758
# listen for updates
5859
async for message in ws:

apps/hip-3-pusher/src/main.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
from loguru import logger
44
import os
55
import sys
6-
import toml
6+
import tomllib
77

8+
from config import Config
89
from hyperliquid_listener import HyperliquidListener
910
from lazer_listener import LazerListener
1011
from hermes_listener import HermesListener
@@ -21,8 +22,10 @@ def load_config():
2122
help="hip3-agent config file",
2223
)
2324
config_path = parser.parse_args().config
24-
with open(config_path, "r") as config_file:
25-
config = toml.load(config_file)
25+
with open(config_path, "rb") as config_file:
26+
config_toml = tomllib.load(config_file)
27+
config = Config(**config_toml)
28+
logger.debug("Config loaded: {}", config)
2629
return config
2730

2831

@@ -46,10 +49,9 @@ async def main():
4649
lazer_listener = LazerListener(config, price_state)
4750
hermes_listener = HermesListener(config, price_state)
4851

49-
# TODO: Probably pull this out of the sdk so we can handle reconnects.
50-
hyperliquid_listener.subscribe()
5152
await asyncio.gather(
5253
publisher.run(),
54+
hyperliquid_listener.subscribe_all(),
5355
lazer_listener.subscribe_all(),
5456
hermes_listener.subscribe_all(),
5557
)

apps/hip-3-pusher/src/metrics.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
from opentelemetry.metrics import get_meter_provider, set_meter_provider
44
from opentelemetry.sdk.metrics import MeterProvider
55

6+
from config import Config
7+
68
METER_NAME = "hip3pusher"
79

810

911
class Metrics:
10-
def __init__(self, config):
12+
def __init__(self, config: Config):
1113
# Adapted from opentelemetry-exporter-prometheus example code.
1214
# Start Prometheus client
13-
start_http_server(port=config["prometheus_port"])
15+
start_http_server(port=config.prometheus_port)
1416
# Exporter to export metrics to Prometheus
1517
reader = PrometheusMetricReader()
1618
# Meter is responsible for creating and recording metrics

apps/hip-3-pusher/src/price_state.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
11
from loguru import logger
22
import time
33

4+
from config import Config
5+
46
DEFAULT_STALE_PRICE_THRESHOLD_SECONDS = 5
57

68

79
class PriceState:
810
"""
911
Maintain latest prices seen across listeners and publisher.
1012
"""
11-
def __init__(self, config):
12-
self.stale_price_threshold_seconds = config.get("stale_price_threshold_seconds", DEFAULT_STALE_PRICE_THRESHOLD_SECONDS)
13+
def __init__(self, config: Config):
14+
self.stale_price_threshold_seconds = config.stale_price_threshold_seconds
1315
now = time.time()
1416

1517
self.hl_oracle_price = None
1618
self.hl_mark_price = None
1719
self.latest_hl_timestamp = now
1820

1921
self.lazer_base_price = None
20-
self.lazer_base_exponent = config["lazer"]["base_feed_exponent"]
22+
self.lazer_base_exponent = config.lazer.base_feed_exponent
2123
self.lazer_quote_price = None
22-
self.lazer_quote_exponent = config["lazer"]["quote_feed_exponent"]
24+
self.lazer_quote_exponent = config.lazer.quote_feed_exponent
2325
self.latest_lazer_timestamp = now
2426

2527
self.hermes_base_price = None
26-
self.hermes_base_exponent = config["hermes"]["base_feed_exponent"]
28+
self.hermes_base_exponent = config.hermes.base_feed_exponent
2729
self.hermes_quote_price = None
28-
self.hermes_quote_exponent = config["hermes"]["quote_feed_exponent"]
30+
self.hermes_quote_exponent = config.hermes.quote_feed_exponent
2931
self.latest_hermes_timestamp = now
3032

3133
def get_current_oracle_price(self):

0 commit comments

Comments
 (0)