Skip to content

Commit cb678a7

Browse files
authored
feat: chainlink/redstone fetchers (#277)
* feat: chainlink/redstone fetchers * feat: add uniBTC
1 parent 3f10044 commit cb678a7

13 files changed

Lines changed: 346 additions & 6 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.8.12"
1+
__version__ = "2.9.0"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.8.12"
1+
__version__ = "2.9.0"

pragma-sdk/pragma_sdk/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.8.12"
1+
__version__ = "2.9.0"

pragma-sdk/pragma_sdk/common/fetchers/fetchers/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from pragma_sdk.common.fetchers.fetchers.kucoin import KucoinFetcher
1010
from pragma_sdk.common.fetchers.fetchers.okx import OkxFetcher
1111
from pragma_sdk.common.fetchers.fetchers.ekubo import EkuboFetcher
12+
from pragma_sdk.common.fetchers.fetchers.chainlink import ChainlinkFetcher
13+
from pragma_sdk.common.fetchers.fetchers.redstone import RedstoneFetcher
1214
from pragma_sdk.common.fetchers.fetchers.mexc import MEXCFetcher
1315
from pragma_sdk.common.fetchers.fetchers.gateio import GateioFetcher
1416
from pragma_sdk.common.fetchers.fetchers.dexscreener import DexscreenerFetcher
@@ -36,4 +38,6 @@
3638
"UpbitFetcher",
3739
"BitgetFetcher",
3840
"LbankFetcher",
41+
"ChainlinkFetcher",
42+
"RedstoneFetcher",
3943
]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
from typing import List, Optional
4+
5+
from pragma_sdk.common.fetchers.fetchers.evm_oracle import (
6+
EVMOracleFeedFetcher,
7+
build_feed_mapping,
8+
)
9+
from pragma_sdk.common.types.pair import Pair
10+
11+
12+
class ChainlinkFetcher(EVMOracleFeedFetcher):
13+
"""Fetches prices from Chainlink Ethereum feeds and rebases them to USD."""
14+
15+
SOURCE = "CHAINLINK"
16+
feed_configs = build_feed_mapping(
17+
[
18+
("LBTC/BTC", "0x5c29868C58b6e15e2b962943278969Ab6a7D3212", 8),
19+
("UNIBTC/BTC", "0x089730f866C6D478398ce1632C7C38677c475EC1", 8),
20+
]
21+
)
22+
23+
def __init__(
24+
self,
25+
pairs: List[Pair],
26+
publisher: str,
27+
api_key: Optional[str] = None,
28+
network: str = "mainnet",
29+
) -> None:
30+
super().__init__(pairs, publisher, api_key, network)
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"""Helpers for fetchers reading on-chain feeds from Ethereum."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import time
7+
from dataclasses import dataclass
8+
from typing import Dict, Iterable, List, Optional, Sequence
9+
10+
from aiohttp import ClientSession
11+
12+
from pragma_sdk.common.exceptions import PublisherFetchError
13+
from pragma_sdk.common.fetchers.handlers.hop_handler import HopHandler
14+
from pragma_sdk.common.fetchers.interface import FetcherInterfaceT
15+
from pragma_sdk.common.logging import get_pragma_sdk_logger
16+
from pragma_sdk.common.types.entry import Entry, SpotEntry
17+
from pragma_sdk.common.types.pair import Pair
18+
19+
20+
logger = get_pragma_sdk_logger()
21+
22+
23+
LATEST_ANSWER_SELECTOR = "0x50d25bcd"
24+
25+
ETHEREUM_RPC_URLS = [
26+
"https://rpc.ankr.com/eth",
27+
"https://eth.llamarpc.com",
28+
"https://cloudflare-eth.com",
29+
"https://eth.llamarpc.com",
30+
"https://ethereum-rpc.publicnode.com",
31+
"https://rpc.mevblocker.io",
32+
]
33+
34+
35+
@dataclass(slots=True)
36+
class FeedConfig:
37+
"""Configuration for an on-chain price feed."""
38+
39+
contract_address: str
40+
decimals: int = 8
41+
42+
43+
class EVMOracleFeedFetcher(FetcherInterfaceT):
44+
"""Base fetcher for Ethereum on-chain feeds returning ratios via ``latestAnswer``."""
45+
46+
SOURCE: str = "EVM_ORACLE"
47+
hop_handler: HopHandler = HopHandler(hopped_currencies={"USD": "BTC"})
48+
feed_configs: Dict[str, FeedConfig] = {}
49+
50+
def __init__(
51+
self,
52+
pairs: List[Pair],
53+
publisher: str,
54+
api_key: Optional[str] = None,
55+
network: str = "mainnet",
56+
rpc_urls: Optional[Sequence[str]] = None,
57+
) -> None:
58+
super().__init__(pairs, publisher, api_key, network)
59+
60+
self._rpc_urls: List[str] = list(rpc_urls) if rpc_urls else ETHEREUM_RPC_URLS
61+
if len(self._rpc_urls) == 0:
62+
raise ValueError("Ethereum RPC URLs list cannot be empty")
63+
self._rpc_index = 0
64+
self._request_id = 0
65+
66+
async def fetch(
67+
self, session: ClientSession
68+
) -> List[Entry | PublisherFetchError | BaseException]:
69+
pairs_to_fetch: List[tuple[Pair, Pair]] = []
70+
requires_hop = False
71+
for requested_pair in self.pairs:
72+
hopped_pair = self.hop_handler.get_hop_pair(requested_pair)
73+
if hopped_pair is not None:
74+
requires_hop = True
75+
pairs_to_fetch.append((requested_pair, hopped_pair))
76+
else:
77+
pairs_to_fetch.append((requested_pair, requested_pair))
78+
79+
hop_prices: Optional[Dict[Pair, float]] = None
80+
if requires_hop:
81+
hop_prices = await self.hop_handler.get_hop_prices(self.client)
82+
83+
tasks = [
84+
asyncio.ensure_future(
85+
self._fetch_single_pair(
86+
requested_pair=pair,
87+
feed_pair=feed_pair,
88+
session=session,
89+
hop_prices=hop_prices,
90+
)
91+
)
92+
for pair, feed_pair in pairs_to_fetch
93+
]
94+
95+
return list(await asyncio.gather(*tasks, return_exceptions=True))
96+
97+
async def fetch_pair(
98+
self, pair: Pair, session: ClientSession
99+
) -> Entry | PublisherFetchError:
100+
"""Required abstract method but we do not use it directly."""
101+
102+
return PublisherFetchError("EVMOracleFeedFetcher uses custom fetch logic")
103+
104+
async def _fetch_single_pair(
105+
self,
106+
requested_pair: Pair,
107+
feed_pair: Pair,
108+
session: ClientSession,
109+
hop_prices: Optional[Dict[Pair, float]],
110+
) -> Entry | PublisherFetchError:
111+
"""Fetch and assemble a spot entry for a single pair."""
112+
113+
feed_key = str(feed_pair)
114+
config = self.feed_configs.get(feed_key)
115+
if config is None:
116+
return PublisherFetchError(
117+
f"No feed configuration for {feed_key} in {self.__class__.__name__}"
118+
)
119+
120+
latest_answer = await self._read_latest_answer(session, config.contract_address)
121+
if isinstance(latest_answer, PublisherFetchError):
122+
return latest_answer
123+
124+
ratio = latest_answer / (10**config.decimals)
125+
126+
price = ratio
127+
if feed_pair != requested_pair:
128+
if hop_prices is None:
129+
return PublisherFetchError(
130+
f"Missing hop prices for {requested_pair} in {self.__class__.__name__}"
131+
)
132+
133+
hop_pair = Pair.from_tickers(
134+
feed_pair.quote_currency.id, requested_pair.quote_currency.id
135+
)
136+
hop_price = hop_prices.get(hop_pair)
137+
if hop_price is None:
138+
return PublisherFetchError(
139+
f"Hop price for {hop_pair} not found while pricing {requested_pair}"
140+
)
141+
price = ratio * hop_price
142+
143+
price_int = int(price * (10 ** requested_pair.decimals()))
144+
timestamp = int(time.time())
145+
146+
return SpotEntry(
147+
pair_id=requested_pair.id,
148+
price=price_int,
149+
volume=0,
150+
timestamp=timestamp,
151+
source=self.SOURCE,
152+
publisher=self.publisher,
153+
)
154+
155+
async def _read_latest_answer(
156+
self, session: ClientSession, contract_address: str
157+
) -> float | PublisherFetchError:
158+
"""Call ``latestAnswer`` on the configured feed, rotating RPCs on failure."""
159+
160+
rpc_candidates = list(self._rpc_urls)
161+
for _ in range(len(rpc_candidates)):
162+
rpc_url = self._next_rpc_url()
163+
payload = {
164+
"jsonrpc": "2.0",
165+
"id": self._next_request_id(),
166+
"method": "eth_call",
167+
"params": [
168+
{"to": contract_address, "data": LATEST_ANSWER_SELECTOR},
169+
"latest",
170+
],
171+
}
172+
173+
try:
174+
async with session.post(
175+
rpc_url,
176+
json=payload,
177+
headers={"Content-Type": "application/json"},
178+
) as resp:
179+
if resp.status != 200:
180+
logger.warning(
181+
"%s received non-200 status %s from %s",
182+
self.__class__.__name__,
183+
resp.status,
184+
rpc_url,
185+
)
186+
continue
187+
188+
data = await resp.json()
189+
except Exception as exc: # pragma: no cover - defensive, network failure
190+
logger.warning(
191+
"%s failed RPC call via %s: %s",
192+
self.__class__.__name__,
193+
rpc_url,
194+
exc,
195+
)
196+
continue
197+
198+
result = data.get("result") if isinstance(data, dict) else None
199+
if result is None:
200+
message = (
201+
data.get("error", {}).get("message")
202+
if isinstance(data, dict)
203+
else data
204+
)
205+
logger.warning(
206+
"%s got empty result from %s: %s",
207+
self.__class__.__name__,
208+
rpc_url,
209+
message,
210+
)
211+
continue
212+
213+
try:
214+
value = int(result, 16)
215+
except ValueError as exc:
216+
logger.error(
217+
"%s received invalid hex result %s: %s",
218+
self.__class__.__name__,
219+
result,
220+
exc,
221+
)
222+
continue
223+
224+
# Convert from uint256 to signed int256 if needed.
225+
if value >= 2**255:
226+
value -= 2**256
227+
228+
return float(value)
229+
230+
return PublisherFetchError(
231+
f"All Ethereum RPCs failed while calling latestAnswer for {contract_address}"
232+
)
233+
234+
def _next_rpc_url(self) -> str:
235+
url = self._rpc_urls[self._rpc_index]
236+
self._rpc_index = (self._rpc_index + 1) % len(self._rpc_urls)
237+
return url
238+
239+
def _next_request_id(self) -> int:
240+
self._request_id += 1
241+
return self._request_id
242+
243+
def format_url(self, pair: Pair) -> str:
244+
None
245+
246+
247+
def build_feed_mapping(
248+
entries: Iterable[tuple[str, str, int]],
249+
) -> Dict[str, FeedConfig]:
250+
"""Utility to build ``feed_configs`` dictionaries.
251+
252+
Args:
253+
entries: iterable of tuples (pair_str, contract_address, decimals)
254+
"""
255+
256+
mapping: Dict[str, FeedConfig] = {}
257+
for pair_str, contract_address, decimals in entries:
258+
mapping[pair_str] = FeedConfig(
259+
contract_address=contract_address, decimals=decimals
260+
)
261+
return mapping

pragma-sdk/pragma_sdk/common/fetchers/fetchers/pyth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"STRK/USD": "0x6a182399ff70ccf3e06024898942028204125a819e519a335ffa4579e66cd870",
5656
"WSTETH/USD": "0x6df640f3b8963d8f8358f791f352b8364513f6ab1cca5ed3f1f7b5448980e784",
5757
"EUR/USD": "0xa995d00bb36a63cef7fd2c287dc105fc8f3d93779f062f09551b0af3e81ec30b",
58+
"LBTC/USD": "0x8f257aab6e7698bb92b15511915e593d6f8eae914452f781874754b03d0c612b",
5859
}
5960

6061

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from __future__ import annotations
2+
3+
from typing import List, Optional
4+
5+
from pragma_sdk.common.fetchers.fetchers.evm_oracle import (
6+
EVMOracleFeedFetcher,
7+
build_feed_mapping,
8+
)
9+
from pragma_sdk.common.types.pair import Pair
10+
11+
12+
class RedstoneFetcher(EVMOracleFeedFetcher):
13+
"""Fetches prices from Redstone Ethereum feeds and rebases them to USD."""
14+
15+
SOURCE = "REDSTONE"
16+
feed_configs = build_feed_mapping(
17+
[
18+
("LBTC/BTC", "0xb415eAA355D8440ac7eCB602D3fb67ccC1f0bc81", 8),
19+
]
20+
)
21+
22+
def __init__(
23+
self,
24+
pairs: List[Pair],
25+
publisher: str,
26+
api_key: Optional[str] = None,
27+
network: str = "mainnet",
28+
) -> None:
29+
super().__init__(pairs, publisher, api_key, network)

pragma-sdk/pragma_sdk/supported_assets.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@
8181
starknet_address: '0x03fe2b97c1fd336e750087d68b9b867997fd64a2661ff3ca5a7c771641e8e7ac'
8282
ethereum_address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599'
8383

84+
- name: 'Lombard Staked Bitcoin'
85+
decimals: 8
86+
ticker: 'LBTC'
87+
coingecko_id: 'lombard-staked-btc'
88+
ethereum_address: '0x8236a87084f8b84306f72007f36f2618a5634494'
89+
90+
- name: 'Universal BTC'
91+
decimals: 8
92+
ticker: 'UNIBTC'
93+
ethereum_address: '0x004e9c3ef86bc1ca1f0bb5c7662861ee93350568'
94+
8495
- name: 'Bitcoin Cash'
8596
decimals: 8
8697
ticker: 'BCH'
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.8.12"
1+
__version__ = "2.9.0"

0 commit comments

Comments
 (0)