Skip to content

Commit f77e3ed

Browse files
authored
feat: HIP-3 oracle pusher
2 parents a8e4004 + 66ad097 commit f77e3ed

File tree

14 files changed

+1292
-0
lines changed

14 files changed

+1292
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Build and Push hip-3-pusher Image
2+
on:
3+
push:
4+
tags:
5+
- hip-3-pusher-v*
6+
pull_request:
7+
paths:
8+
- "apps/hip-3-pusher/**"
9+
workflow_dispatch:
10+
inputs:
11+
dispatch_description:
12+
description: "Dispatch description"
13+
required: true
14+
type: string
15+
permissions:
16+
contents: read
17+
id-token: write
18+
packages: write
19+
env:
20+
REGISTRY: ghcr.io
21+
IMAGE_NAME: pyth-network/hip-3-pusher
22+
jobs:
23+
hip-3-pusher-image:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v2
27+
- name: Set image tag to version of the git tag
28+
if: ${{ startsWith(github.ref, 'refs/tags/hip-3-pusher-v') }}
29+
run: |
30+
PREFIX="refs/tags/hip-3-pusher-"
31+
VERSION="${GITHUB_REF:${#PREFIX}}"
32+
echo "IMAGE_TAG=${VERSION}" >> "${GITHUB_ENV}"
33+
- name: Set image tag to the git commit hash
34+
if: ${{ !startsWith(github.ref, 'refs/tags/hip-3-pusher-v') }}
35+
run: |
36+
echo "IMAGE_TAG=${{ github.sha }}" >> "${GITHUB_ENV}"
37+
- name: Log in to the Container registry
38+
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
39+
with:
40+
registry: ${{ env.REGISTRY }}
41+
username: ${{ github.actor }}
42+
password: ${{ secrets.GITHUB_TOKEN }}
43+
- name: Extract metadata (tags, labels) for Docker
44+
id: metadata_hip_3_pusher
45+
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
46+
with:
47+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
48+
- name: Build and push server docker image
49+
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
50+
with:
51+
context: .
52+
file: "./apps/hip-3-pusher/Dockerfile"
53+
push: ${{ github.event_name != 'pull_request' }}
54+
tags: ${{ steps.metadata_hip_3_pusher.outputs.tags }}
55+
labels: ${{ steps.metadata_hip_3_pusher.outputs.labels }}

apps/hip-3-pusher/.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Python-generated files
2+
__pycache__/
3+
*.py[oc]
4+
build/
5+
dist/
6+
wheels/
7+
*.egg-info
8+
9+
# Virtual environments
10+
.venv

apps/hip-3-pusher/Dockerfile

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Use a Python image with uv pre-installed
2+
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
3+
4+
# Install the project into `/app`
5+
WORKDIR /app
6+
7+
# Enable bytecode compilation
8+
ENV UV_COMPILE_BYTECODE=1
9+
10+
# Copy from the cache instead of linking since it's a mounted volume
11+
ENV UV_LINK_MODE=copy
12+
13+
# Ensure installed tools can be executed out of the box
14+
ENV UV_TOOL_BIN_DIR=/usr/local/bin
15+
16+
COPY apps/hip-3-pusher/uv.lock .
17+
COPY apps/hip-3-pusher/pyproject.toml .
18+
19+
# Install the project's dependencies using the lockfile and settings
20+
RUN --mount=type=cache,target=/root/.cache/uv \
21+
--mount=type=bind,source=apps/hip-3-pusher/uv.lock,target=uv.lock \
22+
--mount=type=bind,source=apps/hip-3-pusher/pyproject.toml,target=pyproject.toml \
23+
uv sync --locked --no-install-project --no-dev
24+
25+
# Then, add the rest of the project source code and install it
26+
# Installing separately from its dependencies allows optimal layer caching
27+
COPY apps/hip-3-pusher/src/ ./src/
28+
COPY apps/hip-3-pusher/config/ ./config/
29+
RUN --mount=type=cache,target=/root/.cache/uv \
30+
uv sync --locked --no-dev
31+
32+
# Run the app by default
33+
CMD ["uv", "run", "src/main.py", "--config", "config/config.toml"]

apps/hip-3-pusher/README.md

Whitespace-only changes.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[hyperliquid]
2+
market_name = ""
3+
market_symbol = "BTC"
4+
use_testnet = false
5+
oracle_pusher_key_path = "/path/to/oracle_pusher_key.txt"
6+
publish_interval = 3.0
7+
enable_publish = false
8+
9+
[kms]
10+
enable_kms = false
11+
key_path = "/path/to/kms_key.txt"
12+
aws_region_name = "ap-northeast-1"
13+
14+
[lazer]
15+
router_urls = ["wss://pyth-lazer-0.dourolabs.app/v1/stream", "wss://pyth-lazer-1.dourolabs.app/v1/stream"]
16+
api_key = "lazer_api_key"
17+
base_feed_id = 1 # BTC
18+
quote_feed_id = 8 # USDT
19+
20+
[hermes]
21+
urls = ["wss://hermes.pyth.network/ws"]
22+
base_id = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" # BTC
23+
quote_id = "2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b" # USDT

apps/hip-3-pusher/pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[project]
2+
name = "hip-3-pusher"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
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+
"toml>=0.10.2",
14+
"websockets>=15.0.1",
15+
]
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import asyncio
2+
import json
3+
from loguru import logger
4+
import websockets
5+
6+
from price_state import PriceState
7+
8+
9+
class HermesListener:
10+
"""
11+
Subscribe to Hermes price updates for needed feeds.
12+
TODO: Will need to handle specific conversions/factors and exponents.
13+
"""
14+
def __init__(self, config, price_state: PriceState):
15+
self.urls = config["hermes"]["urls"]
16+
self.base_id = config["hermes"]["base_id"]
17+
self.quote_id = config["hermes"]["quote_id"]
18+
self.price_state = price_state
19+
20+
def get_subscribe_request(self):
21+
return {
22+
"type": "subscribe",
23+
"ids": [self.base_id, self.quote_id],
24+
"verbose": False,
25+
"binary": True,
26+
"allow_out_of_order": False,
27+
"ignore_invalid_price_ids": False,
28+
}
29+
30+
async def subscribe_all(self):
31+
await asyncio.gather(*(self.subscribe_single(url) for url in self.urls))
32+
33+
async def subscribe_single(self, url):
34+
while True:
35+
try:
36+
await self.subscribe_single_inner(url)
37+
except websockets.ConnectionClosed:
38+
logger.error("Connection to {} closed; retrying", url)
39+
except Exception as e:
40+
logger.exception("Error on {}: {}", url, e)
41+
42+
async def subscribe_single_inner(self, url):
43+
async with websockets.connect(url) as ws:
44+
subscribe_request = self.get_subscribe_request()
45+
46+
await ws.send(json.dumps(subscribe_request))
47+
logger.info("Sent Hermes subscribe request to {}", url)
48+
49+
# listen for updates
50+
async for message in ws:
51+
try:
52+
data = json.loads(message)
53+
self.parse_hermes_message(data)
54+
except json.JSONDecodeError as e:
55+
logger.error("Failed to decode JSON message: {}", e)
56+
57+
def parse_hermes_message(self, data):
58+
"""
59+
For now, simply insert received prices into price_state
60+
61+
:param data: Hermes price update json message
62+
:return: None (update price_state)
63+
"""
64+
try:
65+
if data.get("type", "") != "price_update":
66+
return
67+
price_feed = data["price_feed"]
68+
id = price_feed["id"]
69+
price_object = data["price_feed"]["price"]
70+
price = price_object["price"]
71+
expo = price_object["expo"]
72+
publish_time = price_object["publish_time"]
73+
logger.debug("Hermes update: {} {} {} {}", id, price, expo, publish_time)
74+
if id == self.base_id:
75+
self.price_state.hermes_base_price = price
76+
if id == self.quote_id:
77+
self.price_state.hermes_quote_price = price
78+
except Exception as e:
79+
logger.error("parse_hermes_message error: {}", e)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from loguru import logger
2+
3+
from hyperliquid.info import Info
4+
from hyperliquid.utils.constants import TESTNET_API_URL, MAINNET_API_URL
5+
6+
from price_state import PriceState
7+
8+
9+
class HyperliquidListener:
10+
"""
11+
Subscribe to any relevant Hyperliquid websocket streams
12+
See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket
13+
"""
14+
def __init__(self, config: dict, price_state: PriceState):
15+
self.market_symbol = config["hyperliquid"]["market_symbol"]
16+
url = TESTNET_API_URL if config["hyperliquid"].get("use_testnet", True) else MAINNET_API_URL
17+
self.info = Info(base_url=url)
18+
self.price_state = price_state
19+
20+
def subscribe(self):
21+
self.info.subscribe({"type": "activeAssetCtx", "coin": self.market_symbol}, self.on_activeAssetCtx)
22+
23+
def on_activeAssetCtx(self, message):
24+
"""
25+
Parse oraclePx and markPx from perp context update
26+
27+
:param message: activeAssetCtx websocket update message
28+
:return: None
29+
"""
30+
ctx = message["data"]["ctx"]
31+
self.price_state.latest_oracle_price = ctx["oraclePx"]
32+
self.price_state.latest_mark_price = ctx["markPx"]
33+
logger.debug("on_activeAssetCtx: oraclePx: {} marketPx: {}", self.price_state.latest_oracle_price, self.price_state.latest_mark_price)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import boto3
2+
from asn1crypto import core
3+
from eth_account.messages import encode_typed_data, _hash_eip191_message
4+
from eth_keys.datatypes import Signature
5+
from eth_utils import keccak, to_hex
6+
from hyperliquid.exchange import Exchange
7+
from hyperliquid.utils.constants import TESTNET_API_URL, MAINNET_API_URL
8+
from hyperliquid.utils.signing import get_timestamp_ms, action_hash, construct_phantom_agent, l1_payload
9+
from loguru import logger
10+
11+
12+
class KMSSigner:
13+
def __init__(self, key_id, aws_region_name, use_testnet):
14+
url = TESTNET_API_URL if use_testnet else MAINNET_API_URL
15+
self.oracle_publisher_exchange: Exchange = Exchange(wallet=None, base_url=url)
16+
17+
self.key_id = key_id
18+
self.client = boto3.client("kms", region_name=aws_region_name)
19+
# Fetch public key once so we can derive address and check recovery id
20+
pub_der = self.client.get_public_key(KeyId=key_id)["PublicKey"]
21+
22+
from cryptography.hazmat.primitives import serialization
23+
pub = serialization.load_der_public_key(pub_der)
24+
numbers = pub.public_numbers()
25+
x = numbers.x.to_bytes(32, "big")
26+
y = numbers.y.to_bytes(32, "big")
27+
uncompressed = b"\x04" + x + y
28+
self.public_key_bytes = uncompressed
29+
self.address = "0x" + keccak(uncompressed[1:])[-20:].hex()
30+
logger.info("KMSSigner address: {}", self.address)
31+
32+
def set_oracle(self, dex, oracle_pxs, all_mark_pxs, external_perp_pxs):
33+
timestamp = get_timestamp_ms()
34+
oracle_pxs_wire = sorted(list(oracle_pxs.items()))
35+
mark_pxs_wire = [sorted(list(mark_pxs.items())) for mark_pxs in all_mark_pxs]
36+
external_perp_pxs_wire = sorted(list(external_perp_pxs.items()))
37+
action = {
38+
"type": "perpDeploy",
39+
"setOracle": {
40+
"dex": dex,
41+
"oraclePxs": oracle_pxs_wire,
42+
"markPxs": mark_pxs_wire,
43+
"externalPerpPxs": external_perp_pxs_wire,
44+
},
45+
}
46+
signature = self.sign_l1_action(
47+
action,
48+
timestamp,
49+
self.oracle_publisher_exchange.base_url == MAINNET_API_URL,
50+
)
51+
return self.oracle_publisher_exchange._post_action(
52+
action,
53+
signature,
54+
timestamp,
55+
)
56+
57+
def sign_l1_action(self, action, nonce, is_mainnet):
58+
hash = action_hash(action, vault_address=None, nonce=nonce, expires_after=None)
59+
phantom_agent = construct_phantom_agent(hash, is_mainnet)
60+
data = l1_payload(phantom_agent)
61+
structured_data = encode_typed_data(full_message=data)
62+
message_hash = _hash_eip191_message(structured_data)
63+
signed = self.sign_message(message_hash)
64+
return {"r": to_hex(signed["r"]), "s": to_hex(signed["s"]), "v": signed["v"]}
65+
66+
def sign_message(self, message_hash: bytes):
67+
resp = self.client.sign(
68+
KeyId=self.key_id,
69+
Message=message_hash,
70+
MessageType="DIGEST",
71+
SigningAlgorithm="ECDSA_SHA_256", # required for secp256k1
72+
)
73+
der_sig = resp["Signature"]
74+
75+
seq = core.Sequence.load(der_sig)
76+
r = int(seq[0].native)
77+
s = int(seq[1].native)
78+
79+
for recovery_id in (0, 1):
80+
candidate = Signature(vrs=(recovery_id, r, s))
81+
pubkey = candidate.recover_public_key_from_msg_hash(message_hash)
82+
if pubkey.to_bytes() == self.public_key_bytes:
83+
v = recovery_id + 27
84+
break
85+
else:
86+
raise ValueError("Failed to determine recovery id")
87+
88+
return {
89+
"r": r,
90+
"s": s,
91+
"v": v,
92+
"signature": Signature(vrs=(v, r, s)).to_bytes().hex(),
93+
}

0 commit comments

Comments
 (0)