Skip to content

Commit 8cd3b85

Browse files
authored
Merge pull request #54 from pyth-network/hermes
hermes support
2 parents 177a31b + ab9149a commit 8cd3b85

File tree

5 files changed

+460
-3
lines changed

5 files changed

+460
-3
lines changed

examples/read_hermes.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env python3
2+
3+
import asyncio
4+
5+
from pythclient.hermes import HermesClient
6+
7+
async def get_hermes_prices():
8+
hermes_client = HermesClient([])
9+
feed_ids = await hermes_client.get_price_feed_ids()
10+
feed_ids_rel = feed_ids[:2]
11+
version_http = 1
12+
version_ws = 1
13+
14+
hermes_client.add_feed_ids(feed_ids_rel)
15+
16+
prices_latest = await hermes_client.get_all_prices(version=version_http)
17+
18+
for feed_id, price_feed in prices_latest.items():
19+
print("Initial prices")
20+
print(f"Feed ID: {feed_id}, Price: {price_feed['price'].price}, Confidence: {price_feed['price'].conf}, Time: {price_feed['price'].publish_time}")
21+
22+
print("Starting web socket...")
23+
ws_call = hermes_client.ws_pyth_prices(version=version_ws)
24+
ws_task = asyncio.create_task(ws_call)
25+
26+
while True:
27+
await asyncio.sleep(5)
28+
if ws_task.done():
29+
break
30+
print("Latest prices:")
31+
for feed_id, price_feed in hermes_client.prices_dict.items():
32+
print(f"Feed ID: {feed_id}, Price: {price_feed['price'].price}, Confidence: {price_feed['price'].conf}, Time: {price_feed['price'].publish_time}")
33+
34+
asyncio.run(get_hermes_prices())

pythclient/hermes.py

+192
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import asyncio
2+
from typing import TypedDict
3+
import httpx
4+
import os
5+
import json
6+
import websockets
7+
8+
from .price_feeds import Price
9+
10+
HERMES_ENDPOINT_HTTPS = "https://hermes.pyth.network/"
11+
HERMES_ENDPOINT_WSS = "wss://hermes.pyth.network/ws"
12+
13+
14+
class PriceFeed(TypedDict):
15+
feed_id: str
16+
price: Price
17+
ema_price: Price
18+
update_data: list[str]
19+
20+
21+
def parse_unsupported_version(version):
22+
if isinstance(version, int):
23+
raise ValueError("Version number {version} not supported")
24+
else:
25+
raise TypeError("Version must be an integer")
26+
27+
28+
class HermesClient:
29+
def __init__(self, feed_ids: list[str], endpoint=HERMES_ENDPOINT_HTTPS, ws_endpoint=HERMES_ENDPOINT_WSS, feed_batch_size=100):
30+
self.feed_ids = feed_ids
31+
self.pending_feed_ids = feed_ids
32+
self.prices_dict: dict[str, PriceFeed] = {}
33+
self.endpoint = endpoint
34+
self.ws_endpoint = ws_endpoint
35+
self.feed_batch_size = feed_batch_size # max number of feed IDs to query at once in https requests
36+
37+
async def get_price_feed_ids(self) -> list[str]:
38+
"""
39+
Queries the Hermes https endpoint for a list of the IDs of all Pyth price feeds.
40+
"""
41+
42+
url = os.path.join(self.endpoint, "api/price_feed_ids")
43+
44+
async with httpx.AsyncClient() as client:
45+
data = (await client.get(url)).json()
46+
47+
return data
48+
49+
def add_feed_ids(self, feed_ids: list[str]):
50+
self.feed_ids += feed_ids
51+
self.feed_ids = list(set(self.feed_ids))
52+
self.pending_feed_ids += feed_ids
53+
54+
@staticmethod
55+
def extract_price_feed_v1(data: dict) -> PriceFeed:
56+
"""
57+
Extracts PriceFeed object from the v1 JSON response (individual price feed) from Hermes.
58+
"""
59+
price = Price.from_dict(data["price"])
60+
ema_price = Price.from_dict(data["ema_price"])
61+
update_data = data["vaa"]
62+
price_feed = {
63+
"feed_id": data["id"],
64+
"price": price,
65+
"ema_price": ema_price,
66+
"update_data": [update_data],
67+
}
68+
return price_feed
69+
70+
@staticmethod
71+
def extract_price_feed_v2(data: dict) -> list[PriceFeed]:
72+
"""
73+
Extracts PriceFeed objects from the v2 JSON response (array of price feeds) from Hermes.
74+
"""
75+
update_data = data["binary"]["data"]
76+
77+
price_feeds = []
78+
79+
for feed in data["parsed"]:
80+
price = Price.from_dict(feed["price"])
81+
ema_price = Price.from_dict(feed["ema_price"])
82+
price_feed = {
83+
"feed_id": feed["id"],
84+
"price": price,
85+
"ema_price": ema_price,
86+
"update_data": update_data,
87+
}
88+
price_feeds.append(price_feed)
89+
90+
return price_feeds
91+
92+
async def get_pyth_prices_latest(self, feedIds: list[str], version=2) -> list[PriceFeed]:
93+
"""
94+
Queries the Hermes https endpoint for the latest price feeds for a list of Pyth feed IDs.
95+
"""
96+
if version==1:
97+
url = os.path.join(self.endpoint, "api/latest_price_feeds")
98+
params = {"ids[]": feedIds, "binary": "true"}
99+
elif version==2:
100+
url = os.path.join(self.endpoint, "v2/updates/price/latest")
101+
params = {"ids[]": feedIds, "encoding": "base64", "parsed": "true"}
102+
else:
103+
parse_unsupported_version(version)
104+
105+
async with httpx.AsyncClient() as client:
106+
data = (await client.get(url, params=params)).json()
107+
108+
if version==1:
109+
results = []
110+
for res in data:
111+
price_feed = self.extract_price_feed_v1(res)
112+
results.append(price_feed)
113+
elif version==2:
114+
results = self.extract_price_feed_v2(data)
115+
116+
return results
117+
118+
async def get_pyth_price_at_time(self, feed_id: str, timestamp: int, version=2) -> PriceFeed:
119+
"""
120+
Queries the Hermes https endpoint for the price feed for a Pyth feed ID at a given timestamp.
121+
"""
122+
if version==1:
123+
url = os.path.join(self.endpoint, "api/get_price_feed")
124+
params = {"id": feed_id, "publish_time": timestamp, "binary": "true"}
125+
elif version==2:
126+
url = os.path.join(self.endpoint, f"v2/updates/price/{timestamp}")
127+
params = {"ids[]": [feed_id], "encoding": "base64", "parsed": "true"}
128+
else:
129+
parse_unsupported_version(version)
130+
131+
async with httpx.AsyncClient() as client:
132+
data = (await client.get(url, params=params)).json()
133+
134+
if version==1:
135+
price_feed = self.extract_price_feed_v1(data)
136+
elif version==2:
137+
price_feed = self.extract_price_feed_v2(data)[0]
138+
139+
return price_feed
140+
141+
async def get_all_prices(self, version=2) -> dict[str, PriceFeed]:
142+
"""
143+
Queries the Hermes http endpoint for the latest price feeds for all feed IDs in the class object.
144+
145+
There is a limit on the number of feed IDs that can be queried at once, so this function queries the feed IDs in batches.
146+
"""
147+
pyth_prices_latest = []
148+
i = 0
149+
while len(self.feed_ids[i : i + self.feed_batch_size]) > 0:
150+
pyth_prices_latest += await self.get_pyth_prices_latest(
151+
self.feed_ids[i : i + self.feed_batch_size],
152+
version=version,
153+
)
154+
i += self.feed_batch_size
155+
156+
return dict([(feed['feed_id'], feed) for feed in pyth_prices_latest])
157+
158+
async def ws_pyth_prices(self, version=1):
159+
"""
160+
Opens a websocket connection to Hermes for latest prices for all feed IDs in the class object.
161+
"""
162+
if version != 1:
163+
parse_unsupported_version(version)
164+
165+
async with websockets.connect(self.ws_endpoint) as ws:
166+
while True:
167+
# add new price feed ids to the ws subscription
168+
if len(self.pending_feed_ids) > 0:
169+
json_subscribe = {
170+
"ids": self.pending_feed_ids,
171+
"type": "subscribe",
172+
"verbose": True,
173+
"binary": True,
174+
}
175+
await ws.send(json.dumps(json_subscribe))
176+
self.pending_feed_ids = []
177+
178+
msg = json.loads(await ws.recv())
179+
if msg.get("type") == "response":
180+
if msg.get("status") != "success":
181+
raise Exception("Error in subscribing to websocket")
182+
try:
183+
if msg["type"] != "price_update":
184+
continue
185+
186+
feed_id = msg["price_feed"]["id"]
187+
new_feed = msg["price_feed"]
188+
189+
self.prices_dict[feed_id] = self.extract_price_feed_v1(new_feed)
190+
191+
except Exception as e:
192+
raise Exception(f"Error in price_update message: {msg}") from e

pythclient/price_feeds.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import base64
22
import binascii
33
from struct import unpack
4-
from typing import List, Literal, Optional, Union, cast
4+
from typing import List, Literal, Optional, Union, cast, TypedDict
55

66
from Crypto.Hash import keccak
77
from loguru import logger
@@ -17,6 +17,11 @@
1717

1818
MAX_MESSAGE_IN_SINGLE_UPDATE_DATA = 255
1919

20+
class PriceDict(TypedDict):
21+
conf: str
22+
expo: int
23+
price: str
24+
publish_time: int
2025

2126
class Price:
2227
def __init__(self, conf, expo, price, publish_time) -> None:
@@ -35,6 +40,16 @@ def to_dict(self):
3540
"price": self.price,
3641
"publish_time": self.publish_time,
3742
}
43+
44+
@staticmethod
45+
def from_dict(price_dict: PriceDict):
46+
return Price(
47+
conf=int(price_dict["conf"]),
48+
expo=price_dict["expo"],
49+
price=int(price_dict["price"]),
50+
publish_time=price_dict["publish_time"],
51+
)
52+
3853

3954

4055
class PriceUpdate:

setup.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from setuptools import setup
22

3-
requirements = ['aiodns', 'aiohttp>=3.7.4', 'backoff', 'base58', 'flake8', 'loguru', 'typing-extensions', 'pytz', 'pycryptodome']
3+
requirements = ['aiodns', 'aiohttp>=3.7.4', 'backoff', 'base58', 'flake8', 'loguru', 'typing-extensions', 'pytz', 'pycryptodome', 'httpx', 'websockets']
44

55
with open('README.md', 'r', encoding='utf-8') as fh:
66
long_description = fh.read()
77

88
setup(
99
name='pythclient',
10-
version='0.1.22',
10+
version='0.1.23',
1111
packages=['pythclient'],
1212
author='Pyth Developers',
1313
author_email='[email protected]',

0 commit comments

Comments
 (0)